mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-07-02 17:52:00 +00:00
Merge branch 'ep/fix-async-race' into ep/conc-msgs
This commit is contained in:
@@ -2533,10 +2533,22 @@ public enum GroupType: Codable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct PublicGroupAccess: Codable, Hashable {
|
||||
public var groupWebPage: String?
|
||||
public var groupDomain: String?
|
||||
public var domainWebPage: Bool = false
|
||||
public var allowEmbedding: Bool = false
|
||||
}
|
||||
|
||||
public struct RelayCapabilities: Codable, Hashable {
|
||||
public var baseWebUrl: String?
|
||||
}
|
||||
|
||||
public struct PublicGroupProfile: Codable, Hashable {
|
||||
public var groupType: GroupType
|
||||
public var groupLink: String
|
||||
public var publicGroupId: String
|
||||
public var publicGroupAccess: PublicGroupAccess?
|
||||
}
|
||||
|
||||
public struct GroupProfile: Codable, NamedChat, Hashable {
|
||||
@@ -2705,6 +2717,7 @@ public struct GroupRelay: Identifiable, Decodable, Equatable, Hashable {
|
||||
public var userChatRelay: UserChatRelay
|
||||
public var relayStatus: RelayStatus
|
||||
public var relayLink: String?
|
||||
public var relayCap: RelayCapabilities
|
||||
public var id: Int64 { groupRelayId }
|
||||
}
|
||||
|
||||
|
||||
+17
-2
@@ -2240,11 +2240,25 @@ object GroupTypeSerializer : KSerializer<GroupType> {
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class PublicGroupAccess(
|
||||
val groupWebPage: String? = null,
|
||||
val groupDomain: String? = null,
|
||||
val domainWebPage: Boolean = false,
|
||||
val allowEmbedding: Boolean = false
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RelayCapabilities(
|
||||
val baseWebUrl: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class PublicGroupProfile(
|
||||
val groupType: GroupType,
|
||||
val groupLink: String,
|
||||
val publicGroupId: String
|
||||
val publicGroupId: String,
|
||||
val publicGroupAccess: PublicGroupAccess? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -2368,7 +2382,8 @@ data class GroupRelay(
|
||||
val groupMemberId: Long,
|
||||
val userChatRelay: UserChatRelay,
|
||||
val relayStatus: RelayStatus,
|
||||
val relayLink: String? = null
|
||||
val relayLink: String? = null,
|
||||
val relayCap: RelayCapabilities
|
||||
) {
|
||||
val id: Long get() = groupRelayId
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ This file is generated automatically.
|
||||
- [Profile](#profile)
|
||||
- [ProxyClientError](#proxyclienterror)
|
||||
- [ProxyError](#proxyerror)
|
||||
- [PublicGroupAccess](#publicgroupaccess)
|
||||
- [PublicGroupData](#publicgroupdata)
|
||||
- [PublicGroupProfile](#publicgroupprofile)
|
||||
- [RCErrorType](#rcerrortype)
|
||||
@@ -157,6 +158,7 @@ This file is generated automatically.
|
||||
- [RcvFileTransfer](#rcvfiletransfer)
|
||||
- [RcvGroupEvent](#rcvgroupevent)
|
||||
- [RcvMsgError](#rcvmsgerror)
|
||||
- [RelayCapabilities](#relaycapabilities)
|
||||
- [RelayProfile](#relayprofile)
|
||||
- [RelayStatus](#relaystatus)
|
||||
- [ReportReason](#reportreason)
|
||||
@@ -2496,6 +2498,7 @@ UpdateRequired:
|
||||
- userChatRelay: [UserChatRelay](#userchatrelay)
|
||||
- relayStatus: [RelayStatus](#relaystatus)
|
||||
- relayLink: string?
|
||||
- relayCap: [RelayCapabilities](#relaycapabilities)
|
||||
|
||||
|
||||
---
|
||||
@@ -3066,6 +3069,17 @@ NO_SESSION:
|
||||
- type: "NO_SESSION"
|
||||
|
||||
|
||||
---
|
||||
|
||||
## PublicGroupAccess
|
||||
|
||||
**Record type**:
|
||||
- groupWebPage: string?
|
||||
- groupDomain: string?
|
||||
- domainWebPage: bool
|
||||
- allowEmbedding: bool
|
||||
|
||||
|
||||
---
|
||||
|
||||
## PublicGroupData
|
||||
@@ -3082,6 +3096,7 @@ NO_SESSION:
|
||||
- groupType: [GroupType](#grouptype)
|
||||
- groupLink: string
|
||||
- publicGroupId: string
|
||||
- publicGroupAccess: [PublicGroupAccess](#publicgroupaccess)?
|
||||
|
||||
|
||||
---
|
||||
@@ -3339,6 +3354,14 @@ ParseError:
|
||||
- parseError: string
|
||||
|
||||
|
||||
---
|
||||
|
||||
## RelayCapabilities
|
||||
|
||||
**Record type**:
|
||||
- baseWebUrl: string?
|
||||
|
||||
|
||||
---
|
||||
|
||||
## RelayProfile
|
||||
|
||||
@@ -327,6 +327,7 @@ chatTypesDocsData =
|
||||
(sti @Profile, STRecord, "", [], "", ""),
|
||||
(sti @ProxyClientError, STUnion, "Proxy", [], "", ""),
|
||||
(sti @ProxyError, STUnion, "", [], "", ""),
|
||||
(sti @PublicGroupAccess, STRecord, "", [], "", ""),
|
||||
(sti @PublicGroupData, STRecord, "", [], "", ""),
|
||||
(sti @PublicGroupProfile, STRecord, "", [], "", ""),
|
||||
(sti @RatchetSyncState, STEnum, "RS", [], "", ""),
|
||||
@@ -338,6 +339,7 @@ chatTypesDocsData =
|
||||
(sti @RcvFileTransfer, STRecord, "", [], "", ""),
|
||||
(sti @RcvGroupEvent, STUnion, "RGE", [], "", ""),
|
||||
(sti @RcvMsgError, STUnion, "RME", [], "", ""),
|
||||
(sti @RelayCapabilities, STRecord, "", [], "", ""),
|
||||
(sti @RelayProfile, STRecord, "", [], "", ""),
|
||||
(sti @RelayStatus, STEnum, "RS", [], "", ""),
|
||||
(sti @ReportReason, STEnum' (dropPfxSfx "RR" ""), "", ["RRUnknown"], "", ""),
|
||||
@@ -546,6 +548,7 @@ deriving instance Generic PreparedGroup
|
||||
deriving instance Generic Profile
|
||||
deriving instance Generic ProxyClientError
|
||||
deriving instance Generic ProxyError
|
||||
deriving instance Generic PublicGroupAccess
|
||||
deriving instance Generic PublicGroupData
|
||||
deriving instance Generic PublicGroupProfile
|
||||
deriving instance Generic RatchetSyncState
|
||||
@@ -557,6 +560,7 @@ deriving instance Generic RcvFileStatus
|
||||
deriving instance Generic RcvFileTransfer
|
||||
deriving instance Generic RcvGroupEvent
|
||||
deriving instance Generic RcvMsgError
|
||||
deriving instance Generic RelayCapabilities
|
||||
deriving instance Generic RelayProfile
|
||||
deriving instance Generic RelayStatus
|
||||
deriving instance Generic ReportReason
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd
|
||||
source-repository-package
|
||||
type: git
|
||||
location: https://github.com/simplex-chat/simplexmq.git
|
||||
tag: 906da42de095b59965859d9397d2fd01f6c128c1
|
||||
tag: 4922812707d791a4e3bfa11d4ce0c77ea5ebb67e
|
||||
|
||||
source-repository-package
|
||||
type: git
|
||||
|
||||
@@ -2770,6 +2770,7 @@ export interface GroupRelay {
|
||||
userChatRelay: UserChatRelay
|
||||
relayStatus: RelayStatus
|
||||
relayLink?: string
|
||||
relayCap: RelayCapabilities
|
||||
}
|
||||
|
||||
export type GroupRootKey = GroupRootKey.Private | GroupRootKey.Public
|
||||
@@ -3351,6 +3352,13 @@ export namespace ProxyError {
|
||||
}
|
||||
}
|
||||
|
||||
export interface PublicGroupAccess {
|
||||
groupWebPage?: string
|
||||
groupDomain?: string
|
||||
domainWebPage: boolean
|
||||
allowEmbedding: boolean
|
||||
}
|
||||
|
||||
export interface PublicGroupData {
|
||||
publicMemberCount: number // int64
|
||||
}
|
||||
@@ -3359,6 +3367,7 @@ export interface PublicGroupProfile {
|
||||
groupType: GroupType
|
||||
groupLink: string
|
||||
publicGroupId: string
|
||||
publicGroupAccess?: PublicGroupAccess
|
||||
}
|
||||
|
||||
export type RCErrorType =
|
||||
@@ -3747,6 +3756,10 @@ export namespace RcvMsgError {
|
||||
}
|
||||
}
|
||||
|
||||
export interface RelayCapabilities {
|
||||
baseWebUrl?: string
|
||||
}
|
||||
|
||||
export interface RelayProfile {
|
||||
displayName: string
|
||||
fullName: string
|
||||
|
||||
@@ -1945,6 +1945,7 @@ class GroupRelay(TypedDict):
|
||||
userChatRelay: "UserChatRelay"
|
||||
relayStatus: "RelayStatus"
|
||||
relayLink: NotRequired[str]
|
||||
relayCap: "RelayCapabilities"
|
||||
|
||||
class GroupRootKey_private(TypedDict):
|
||||
type: Literal["private"]
|
||||
@@ -2353,6 +2354,12 @@ ProxyError = ProxyError_PROTOCOL | ProxyError_BROKER | ProxyError_BASIC_AUTH | P
|
||||
|
||||
ProxyError_Tag = Literal["PROTOCOL", "BROKER", "BASIC_AUTH", "NO_SESSION"]
|
||||
|
||||
class PublicGroupAccess(TypedDict):
|
||||
groupWebPage: NotRequired[str]
|
||||
groupDomain: NotRequired[str]
|
||||
domainWebPage: bool
|
||||
allowEmbedding: bool
|
||||
|
||||
class PublicGroupData(TypedDict):
|
||||
publicMemberCount: int # int64
|
||||
|
||||
@@ -2360,6 +2367,7 @@ class PublicGroupProfile(TypedDict):
|
||||
groupType: "GroupType"
|
||||
groupLink: str
|
||||
publicGroupId: str
|
||||
publicGroupAccess: NotRequired["PublicGroupAccess"]
|
||||
|
||||
class RCErrorType_internal(TypedDict):
|
||||
type: Literal["internal"]
|
||||
@@ -2628,6 +2636,9 @@ RcvMsgError = RcvMsgError_dropped | RcvMsgError_parseError
|
||||
|
||||
RcvMsgError_Tag = Literal["dropped", "parseError"]
|
||||
|
||||
class RelayCapabilities(TypedDict):
|
||||
baseWebUrl: NotRequired[str]
|
||||
|
||||
class RelayProfile(TypedDict):
|
||||
displayName: str
|
||||
fullName: str
|
||||
|
||||
@@ -0,0 +1,596 @@
|
||||
# Channel Web Preview
|
||||
|
||||
## Context
|
||||
|
||||
SimpleX channels are public - anybody with the link to join and chat relays rebroadcasting the messages can see content. To grow channels, owners need a public web preview (like Telegram's `t.me/s/channelname`) showing the last 50 messages. This lets potential subscribers browse before joining.
|
||||
|
||||
The relay already stores all messages in its database. The web preview is a periodic read-and-render loop that writes JSON files served by Caddy, with CORS controlling which domains can embed the preview.
|
||||
|
||||
This feature integrates with the `.simplex` namespace (ENS-based names resolving to channel links). A channel's registered domain (`groupDomain`) lives in `PublicGroupAccess` inside `PublicGroupProfile` and is disseminated with the profile. On-chain verification of the domain is deferred until RSLV resolution protocol ships.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
simplex-chat CLI (--relay --web-json-dir=... --web-base-url=...)
|
||||
├── Main chat loop (existing)
|
||||
├── Relay logic (existing, gated by --relay)
|
||||
└── Web preview thread (new, gated by relayWebOptions)
|
||||
├── Periodic: load publishable groups → render JSON → write files
|
||||
└── Regenerate Caddy CORS config → caddy reload
|
||||
|
||||
Caddy (operator-configured)
|
||||
├── Serves JSON at <baseWebUrl>/<publicGroupId>.json
|
||||
└── Imports generated CORS config file
|
||||
|
||||
Channel page (static HTML+JS, hosted by owner or on GitHub)
|
||||
├── Fetches JSON from relay(s) with fallback
|
||||
└── Renders messages, shows join button
|
||||
```
|
||||
|
||||
## Data Model Changes
|
||||
|
||||
### 1. Extend `PublicGroupProfile` with domain and web access settings
|
||||
|
||||
**File:** `src/Simplex/Chat/Types.hs` (line 796)
|
||||
|
||||
Current:
|
||||
```haskell
|
||||
data PublicGroupProfile = PublicGroupProfile
|
||||
{ groupType :: GroupType,
|
||||
groupLink :: ShortLinkContact,
|
||||
publicGroupId :: B64UrlByteString
|
||||
}
|
||||
```
|
||||
|
||||
New:
|
||||
```haskell
|
||||
data PublicGroupAccess = PublicGroupAccess
|
||||
{ groupWebPage :: Maybe Text, -- channel's web page URL (adds CORS origin)
|
||||
groupDomain :: Maybe Text, -- domain for this channel (must have link set in domain record in the contract)
|
||||
domainWebPage :: Bool, -- show on the domain's page (e.g. simplexnetwork.org site for simplex TLD domains, or domain site for web domains)
|
||||
allowEmbeding :: Bool -- allow embedding from any origin (CORS: *)
|
||||
}
|
||||
|
||||
data PublicGroupProfile = PublicGroupProfile
|
||||
{ groupType :: GroupType,
|
||||
groupLink :: ShortLinkContact,
|
||||
publicGroupId :: B64UrlByteString,
|
||||
publicGroupAccess :: Maybe PublicGroupAccess -- NEW: web preview settings
|
||||
}
|
||||
```
|
||||
|
||||
`groupDomain` stores the channel's registered `.simplex` domain name or another supported TLD. It is:
|
||||
- Set by the owner after registering a name on-chain
|
||||
- Disseminated to all members via `GroupProfile` (nested in `publicGroup`)
|
||||
- Used by `simplexnetwork.org/c/<name>` to route to the channel's web preview (for .simplex domain)
|
||||
|
||||
JSON instances: TH-derived `$(JQ.deriveJSON defaultJSON ''PublicGroupAccess)`. Existing `$(JQ.deriveJSON defaultJSON ''PublicGroupProfile)` covers the new optional field.
|
||||
|
||||
**Migration (SQLite/Postgres):** separate columns, same pattern as `group_type`/`group_link`/`public_group_id`:
|
||||
```sql
|
||||
ALTER TABLE group_profiles ADD COLUMN group_web_page TEXT;
|
||||
ALTER TABLE group_profiles ADD COLUMN group_domain TEXT;
|
||||
ALTER TABLE group_profiles ADD COLUMN domain_web_page INTEGER;
|
||||
ALTER TABLE group_profiles ADD COLUMN allow_embedding INTEGER;
|
||||
ALTER TABLE group_profiles ADD COLUMN group_domain_verified_at TEXT;
|
||||
```
|
||||
|
||||
`group_domain_verified_at` is relay-local verification state (nullable timestamp, NULL = unverified).
|
||||
|
||||
**Store changes:**
|
||||
|
||||
`src/Simplex/Chat/Store/Shared.hs` line 693 - new constructor alongside `toPublicGroupProfile`:
|
||||
```haskell
|
||||
toPublicGroupAccess :: Maybe Text -> Maybe Text -> Maybe BoolInt -> Maybe BoolInt -> Maybe PublicGroupAccess
|
||||
toPublicGroupAccess groupWebPage groupDomain domainWebPage_ allowEmbeding_
|
||||
| isJust groupWebPage || isJust groupDomain || fromBI domainWebPage_ || fromBI allowEmbeding_ =
|
||||
Just PublicGroupAccess {groupWebPage, groupDomain, domainWebPage = fromBI domainWebPage_, allowEmbeding = fromBI allowEmbeding_}
|
||||
| otherwise = Nothing
|
||||
where fromBI = maybe False unBI
|
||||
```
|
||||
|
||||
Extend `toPublicGroupProfile` to accept and pass through `Maybe PublicGroupAccess`.
|
||||
|
||||
`GroupInfoRow` type (line 668) gains columns for: `group_web_page`, `group_domain`, `domain_web_page`, `allow_embedding`, `group_domain_verified_at`.
|
||||
|
||||
`src/Simplex/Chat/Store/Groups.hs`:
|
||||
- INSERT (line 367): add all new columns
|
||||
- SELECT (line 2375): add `gp.group_web_page`, `gp.group_domain`, `gp.domain_web_page`, `gp.allow_embedding`, `gp.group_domain_verified_at`
|
||||
- UPDATE (line 1922): include new columns in `updateGroupProfile_`
|
||||
|
||||
### 2. `RelayCapabilities` record, extend `XGrpRelayAcpt`, new `XGrpRelayCap`
|
||||
|
||||
**File:** `src/Simplex/Chat/Protocol.hs`
|
||||
|
||||
New record for relay capabilities (extensible for future fields):
|
||||
```haskell
|
||||
data RelayCapabilities = RelayCapabilities
|
||||
{ baseWebUrl :: Maybe Text
|
||||
}
|
||||
```
|
||||
|
||||
TH-derived JSON. All fields optional so old relays produce `{}` and new fields are backward compatible.
|
||||
|
||||
**`XGrpRelayAcpt`** - carries capabilities at acceptance time:
|
||||
|
||||
Current (line 444): `XGrpRelayAcpt :: ShortLinkContact -> ChatMsgEvent 'Json`
|
||||
New: `XGrpRelayAcpt :: ShortLinkContact -> RelayCapabilities -> ChatMsgEvent 'Json`
|
||||
|
||||
Parsing: `XGrpRelayAcpt_ -> XGrpRelayAcpt <$> p "relayLink" <*> (p "relayCap" <|> pure defaultRelayCap)`
|
||||
Encoding: `XGrpRelayAcpt relayLink cap -> o ["relayLink" .= relayLink, "relayCap" .= cap]`
|
||||
Backward compatible: old relays omit `relayCap`, parsed as default (all `Nothing`).
|
||||
|
||||
**`XGrpRelayCap`** - new message for ongoing capability updates:
|
||||
|
||||
```haskell
|
||||
XGrpRelayCap :: RelayCapabilities -> ChatMsgEvent 'Json
|
||||
```
|
||||
|
||||
Tag: `"x.grp.relay.cap"`
|
||||
Parsing: `XGrpRelayCap_ -> XGrpRelayCap <$> p "relayCap"`
|
||||
Encoding: `XGrpRelayCap cap -> o ["relayCap" .= cap]`
|
||||
|
||||
Sent by relay to owner only when capabilities change (not periodic). Relay detects change by comparing current config against persisted state on startup.
|
||||
|
||||
### 3. Store `baseWebUrl` per relay
|
||||
|
||||
**File:** `src/Simplex/Chat/Operators.hs` (line 278)
|
||||
|
||||
Current:
|
||||
```haskell
|
||||
data GroupRelay = GroupRelay
|
||||
{ groupRelayId :: Int64,
|
||||
groupMemberId :: Int64,
|
||||
userChatRelay :: UserChatRelay,
|
||||
relayStatus :: RelayStatus,
|
||||
relayLink :: Maybe ShortLinkContact
|
||||
}
|
||||
```
|
||||
|
||||
Add: `relayCap :: Maybe RelayCapabilities`
|
||||
|
||||
Stored as separate columns (same pattern as `PublicGroupAccess`):
|
||||
**Migration:** `ALTER TABLE group_relays ADD COLUMN base_web_url TEXT`
|
||||
|
||||
`relayCap` constructed from columns: `Just RelayCapabilities {baseWebUrl}` when any capability column is non-NULL, `Nothing` otherwise.
|
||||
|
||||
**Handlers in `src/Simplex/Chat/Library/Subscriber.hs`:**
|
||||
- `XGrpRelayAcpt` (line 770): store `RelayCapabilities` in relay record on acceptance
|
||||
- `XGrpRelayCap` (new handler): update `RelayCapabilities` in relay record; only accepted from relay members (`isRelay m`), owner receives
|
||||
|
||||
**Relay-side persistence:** relay persists its current `RelayCapabilities` (derived from `RelayWebOptions`) so it can detect config changes on restart. On startup, if persisted capabilities differ from config, relay sends `XGrpRelayCap` to all group owners it serves.
|
||||
|
||||
### 4. CLI options for web preview
|
||||
|
||||
**File:** `src/Simplex/Chat/Options.hs`
|
||||
|
||||
New record bundling all web preview options:
|
||||
```haskell
|
||||
data RelayWebOptions = RelayWebOptions
|
||||
{ webJsonDir :: FilePath, -- --web-json-dir: where to write JSON files
|
||||
webBaseUrl :: Text, -- --web-base-url: public URL prefix (sent in XGrpRelayAcpt)
|
||||
webCorsFile :: FilePath, -- --web-cors-file: generated Caddy CORS config path
|
||||
webUpdateInterval :: Int -- --web-update-interval: seconds (default 300)
|
||||
}
|
||||
```
|
||||
|
||||
Add as a proper field in `CoreChatOpts`:
|
||||
```haskell
|
||||
data CoreChatOpts = CoreChatOpts
|
||||
{ ...existing...,
|
||||
relayWebOptions :: Maybe RelayWebOptions
|
||||
}
|
||||
```
|
||||
|
||||
Parsed from CLI: when `--web-json-dir` is provided, all other `--web-*` flags are required. `Nothing` when no web preview flags are set. Only meaningful when `--relay` is also set.
|
||||
|
||||
### 5. Web preview thread startup
|
||||
|
||||
**File:** `src/Simplex/Chat/Core.hs` (line 74)
|
||||
|
||||
Current:
|
||||
```haskell
|
||||
runSimplexChat ... = do
|
||||
a1 <- runReaderT (startChatController True True) cc
|
||||
when (chatRelay && not testView) $ askCreateRelayAddress cc u
|
||||
forM_ (postStartHook chatHooks) ($ cc)
|
||||
a2 <- async $ chat u cc
|
||||
waitEither_ a1 a2
|
||||
```
|
||||
|
||||
Add web preview thread as a third async when config is present:
|
||||
```haskell
|
||||
runSimplexChat ... = do
|
||||
a1 <- runReaderT (startChatController True True) cc
|
||||
when (chatRelay && not testView) $ askCreateRelayAddress cc u
|
||||
forM_ (postStartHook chatHooks) ($ cc)
|
||||
a2 <- async $ chat u cc
|
||||
case relayWebOptions coreOptions of
|
||||
Nothing -> waitEither_ a1 a2
|
||||
Just webOpts -> do
|
||||
a3 <- async $ webPreviewThread webOpts cc
|
||||
void $ waitAnyCancel [a1, a2, a3]
|
||||
```
|
||||
|
||||
## New Types for JSON Serialization
|
||||
|
||||
**File:** new module `src/Simplex/Chat/Web/Preview.hs`
|
||||
|
||||
### Reuse as-is (existing ToJSON instances)
|
||||
|
||||
- `GroupProfile` (Types.hs:803) - channel metadata (displayName, fullName, shortDescr, description, image, publicGroup incl. groupDomain)
|
||||
- `MsgContent` (Protocol.hs:689) - tagged union: MCText, MCLink, MCImage, MCVideo, etc.
|
||||
- `LinkPreview` (Protocol.hs:256) - `{uri, title, description, image, content}`
|
||||
- `FormattedText` / `MarkdownList` (Markdown.hs:133/139) - parsed markdown
|
||||
- `QuotedMsg` / `MsgRef` (Protocol.hs:589) - quoted message context
|
||||
- `MsgMentions` = `Map MemberName CIMention` (Messages.hs:264)
|
||||
- `CIMention` (Messages.hs:272) - `{memberId, memberRef}`
|
||||
- `CIReactionCount` (Messages.hs:338) - `{reaction, userReacted, totalReacted}`
|
||||
|
||||
### New types
|
||||
|
||||
```haskell
|
||||
data WebFileInfo = WebFileInfo
|
||||
{ fileName :: String,
|
||||
fileSize :: Integer
|
||||
}
|
||||
|
||||
data WebMemberProfile = WebMemberProfile
|
||||
{ memberId :: MemberId,
|
||||
displayName :: Text,
|
||||
image :: Maybe ImageData
|
||||
}
|
||||
|
||||
data WebMessage = WebMessage
|
||||
{ sender :: Maybe MemberId, -- Nothing for CIChannelRcv (forwarded-from-channel)
|
||||
ts :: UTCTime,
|
||||
content :: MsgContent,
|
||||
formattedText :: Maybe MarkdownList,
|
||||
file :: Maybe WebFileInfo,
|
||||
quote :: Maybe QuotedMsg,
|
||||
mentions :: Map MemberName CIMention,
|
||||
reactions :: [CIReactionCount],
|
||||
forwarded :: Maybe CIForwardedFrom,
|
||||
edited :: Bool
|
||||
}
|
||||
|
||||
data WebChannelPreview = WebChannelPreview
|
||||
{ channel :: GroupProfile, -- NOTE: render loop strips groupDomain until verified
|
||||
subscriberCount :: Maybe Int,
|
||||
members :: [WebMemberProfile],
|
||||
messages :: [WebMessage],
|
||||
updatedAt :: UTCTime
|
||||
}
|
||||
```
|
||||
|
||||
TH-derived JSON for `WebFileInfo`, `WebMemberProfile`, `WebMessage`, `WebChannelPreview`.
|
||||
|
||||
## Render Loop
|
||||
|
||||
**File:** new module `src/Simplex/Chat/Web.hs`
|
||||
|
||||
Pattern from directory service's `updateListingsThread_` (Service.hs:185-194).
|
||||
|
||||
```haskell
|
||||
webPreviewThread :: RelayWebOptions -> ChatController -> IO ()
|
||||
webPreviewThread opts cc = forever $ do
|
||||
u_ <- readTVarIO $ currentUser cc
|
||||
forM_ u_ $ \user -> do
|
||||
groups <- getWebPublishGroups cc user
|
||||
corsEntries <- forM groups $ \gInfo -> do
|
||||
renderGroupPreview opts cc user gInfo
|
||||
pure (corsEntry gInfo)
|
||||
writeCorsConfig opts corsEntries
|
||||
threadDelay (webUpdateInterval opts * 1_000_000)
|
||||
```
|
||||
|
||||
### Loading groups
|
||||
|
||||
New store function `getWebPublishGroups`:
|
||||
```sql
|
||||
SELECT ... FROM groups g
|
||||
JOIN group_profiles gp ON g.group_profile_id = gp.group_profile_id
|
||||
WHERE gp.group_web_page IS NOT NULL
|
||||
AND g.user_id = ?
|
||||
```
|
||||
|
||||
Returns `[GroupInfo]`. For each, call `getGroupChat` with `CPLast 50` (Store/Messages.hs:1436) to get chat items.
|
||||
|
||||
### Converting CChatItem to WebMessage
|
||||
|
||||
For each `CChatItem SMDRcv (ChatItem {chatDir, meta, content, mentions, formattedText, quotedItem, reactions, file})`:
|
||||
|
||||
1. **Skip if:**
|
||||
- `itemDeleted meta` is `Just _`
|
||||
- `itemTimed meta` is `Just _`
|
||||
- `content` is not `CIRcvMsgContent mc` (skip `CIRcvGroupEvent`, `CIRcvIntegrityError`, etc.)
|
||||
- `mc` is `MCReport` or `MCUnknown`
|
||||
|
||||
2. **Extract sender:**
|
||||
- `CIGroupRcv member` -> `Just (memberId member)`, collect member into profiles array
|
||||
- `CIChannelRcv` -> `Nothing` (channel-forwarded message, no individual sender)
|
||||
|
||||
3. **Extract file info:**
|
||||
- `file :: Maybe (CIFile 'MDRcv)` has `fileName :: String`, `fileSize :: Integer`
|
||||
- Strip `fileSource`, `fileStatus`, `fileProtocol` (download metadata irrelevant for web)
|
||||
|
||||
4. **Build WebMessage:**
|
||||
```haskell
|
||||
WebMessage
|
||||
{ sender = senderMemberId
|
||||
, ts = itemTs meta
|
||||
, content = mc
|
||||
, formattedText = formattedText
|
||||
, file = (\f -> WebFileInfo (fileName f) (fileSize f)) <$> file
|
||||
, quote = quotedItem -- QuotedMsg reused directly
|
||||
, mentions = mentions
|
||||
, reactions = reactions
|
||||
, forwarded = itemForwarded meta
|
||||
, edited = itemEdited meta
|
||||
}
|
||||
```
|
||||
|
||||
5. **Collect unique senders** into `[WebMemberProfile]` from `GroupMember` records in `CIGroupRcv`.
|
||||
|
||||
Also include `CIGroupSnd` items (relay's own sent messages, if any - unlikely but possible for admin announcements).
|
||||
|
||||
### Filtering unverified domains
|
||||
|
||||
Before serializing, the render loop strips `groupDomain` from the `PublicGroupAccess` included in the profile when not verified:
|
||||
|
||||
```haskell
|
||||
stripUnverifiedDomain :: Maybe UTCTime -> GroupProfile -> GroupProfile
|
||||
stripUnverifiedDomain verifiedAt gp = case verifiedAt of
|
||||
Just _ -> gp -- domain verified, include as-is
|
||||
Nothing -> gp {publicGroup = clearDomain <$> publicGroup gp}
|
||||
where
|
||||
clearDomain pgp = pgp {publicGroupAccess = clearAccess <$> publicGroupAccess pgp}
|
||||
clearAccess acc = acc {groupDomain = ""} -- or strip the access record entirely
|
||||
```
|
||||
|
||||
The `group_domain_verified_at` timestamp is loaded alongside the group info. Until RSLV ships, this column is always NULL, so all domains are stripped from web export.
|
||||
|
||||
`domainWebPage` in CORS config is also gated on verified domain - unverified means no domain-site origin in CORS.
|
||||
|
||||
### Writing JSON
|
||||
|
||||
- Serialize `WebChannelPreview` to JSON via `Data.Aeson.encode`
|
||||
- Write atomically (write to temp, rename) to `<webJsonDir>/<publicGroupId>.json`
|
||||
- `publicGroupId` from `PublicGroupProfile` (base64url-encoded, existing field)
|
||||
|
||||
### Generating Caddy CORS config
|
||||
|
||||
Write a single file with Caddy `map` directive:
|
||||
|
||||
```caddy
|
||||
map {path} {cors_origin} {
|
||||
/<publicGroupId1>.json "https://owner-domain.com"
|
||||
/<publicGroupId2>.json "*"
|
||||
default ""
|
||||
}
|
||||
header /*.json Access-Control-Allow-Origin {cors_origin}
|
||||
header /*.json Access-Control-Allow-Methods "GET, OPTIONS"
|
||||
```
|
||||
|
||||
CORS origin derivation from `PublicGroupAccess`:
|
||||
- `allowEmbeding = True` -> `*`
|
||||
- `groupWebPage = Just url` -> extract origin from URL (+ domain site origin if `domainWebPage` and domain verified)
|
||||
- `groupWebPage = Nothing, domainWebPage = True` -> domain site origin only (when domain is verified)
|
||||
- No web page, no embedding, no domain page -> omit from config
|
||||
|
||||
After writing, run `caddy reload` if file content changed (compare hash before/after).
|
||||
|
||||
## Namespace Integration
|
||||
|
||||
`groupDomain` ships now in the profile (inside `PublicGroupAccess`). What's deferred is on-chain verification (RSLV protocol).
|
||||
|
||||
### What ships now
|
||||
|
||||
1. **`groupDomain :: Text` in `PublicGroupAccess`** - owner sets the registered domain, disseminated to all members
|
||||
2. **`domainWebPage :: Bool` in `PublicGroupAccess`** - flag stored but has no effect until domain is verified
|
||||
3. **Relay strips `groupDomain` from web export** - no verification means domain is cleared in JSON, no domain-site CORS origin
|
||||
|
||||
### What ships with RSLV
|
||||
|
||||
1. **RSLV protocol** - relay queries name servers via SMP proxy to verify domain ownership
|
||||
2. **`domainWebPage` becomes functional** - enables domain-site hosting (e.g. `simplexnetwork.org/c/<name>`) for verified domains
|
||||
3. **In-app resolution** - `#name` markdown (already parsed by namespace branch) resolves and connects
|
||||
|
||||
### Verification flow (relay-side)
|
||||
|
||||
When owner updates profile with `groupDomain`:
|
||||
|
||||
1. **Trigger:** Relay receives profile update on owner's connection containing `groupDomain` field
|
||||
2. **Initiate:** Relay sends `RSLV <namehash>` through SMP proxy (async, on the same owner connection context)
|
||||
3. **Pending state:** `group_domain_verified_at = NULL` in DB. Web export excludes domain while pending.
|
||||
4. **Resolution arrives:** `NAME <record>` agent event arrives on the owner's connection (continuation bound to the connection that sent the profile update)
|
||||
5. **Verify:** Check if `channelLinks` in the NAME response includes this group's `groupLink`
|
||||
6. **Store result:** Set `group_domain_verified_at = <current_time>` on success, leave NULL on failure
|
||||
7. **Effect:** Web render loop includes domain in JSON and enables domain-site CORS only when `group_domain_verified_at IS NOT NULL`
|
||||
|
||||
Re-verification: periodic (e.g. daily or on each web update cycle) to catch expired/transferred domains. Clear `group_domain_verified_at` when re-verification fails.
|
||||
|
||||
### What the namespace branch already provides
|
||||
|
||||
- `SimplexNameInfo {nameType, namespace, domain, subDomain}` in Markdown.hs
|
||||
- `SimplexName` variant in `Format` ADT
|
||||
- Parser for `#name` / `#name.simplex` / `:name.simplex` syntax
|
||||
- Forward-compatibility alerts in Kotlin/Swift UI (shows "requires newer app" until resolution is implemented)
|
||||
|
||||
## UI Changes (Kotlin/Swift)
|
||||
|
||||
### Kotlin types
|
||||
|
||||
**File:** `apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt`
|
||||
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class PublicGroupAccess(
|
||||
val groupWebPage: String? = null,
|
||||
val groupDomain: String? = null,
|
||||
val domainWebPage: Boolean = false,
|
||||
val allowEmbeding: Boolean = false
|
||||
)
|
||||
|
||||
// Extend existing PublicGroupProfile (currently at line 2213):
|
||||
@Serializable
|
||||
data class PublicGroupProfile(
|
||||
val groupType: GroupType,
|
||||
val groupLink: String,
|
||||
val publicGroupId: String,
|
||||
val publicGroupAccess: PublicGroupAccess? = null // NEW
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RelayCapabilities(
|
||||
val baseWebUrl: String? = null
|
||||
)
|
||||
|
||||
// Extend existing GroupRelay:
|
||||
@Serializable
|
||||
data class GroupRelay(
|
||||
...existing fields...,
|
||||
val relayCap: RelayCapabilities? = null // NEW
|
||||
)
|
||||
```
|
||||
|
||||
### Owner: Channel info page
|
||||
|
||||
**File:** `GroupChatInfoView.kt` (around line 604-606)
|
||||
|
||||
After existing `ChannelLinkButton(manageGroupLink)`:
|
||||
```kotlin
|
||||
ChannelWebPageButton(openChannelWebPage) // owner only
|
||||
```
|
||||
|
||||
New nav destination opens `ChannelWebPageView`.
|
||||
|
||||
### Owner: Channel web page screen
|
||||
|
||||
**File:** new `apps/multiplatform/.../views/chat/group/ChannelWebPageView.kt`
|
||||
|
||||
- Text field: web page URL (`groupWebPage`)
|
||||
- Text field: domain (`groupDomain`)
|
||||
- Toggle: allow embedding (`allowEmbeding`)
|
||||
- Toggle: show on domain's page (`domainWebPage`) - stored but inert until RSLV ships
|
||||
- Section: embed snippet (read-only, auto-generated from relay `baseWebUrl` values + `publicGroupId`)
|
||||
- Save button -> `apiUpdateGroup` with updated `GroupProfile`
|
||||
|
||||
### Subscriber: Channel info page
|
||||
|
||||
In the top section (around line 607-614), after channel link QR:
|
||||
```kotlin
|
||||
val webPageUrl = groupInfo.groupProfile.publicGroup?.publicGroupAccess?.groupWebPage
|
||||
if (webPageUrl != null) {
|
||||
WebPageLinkRow(webPageUrl) // clickable, opens browser
|
||||
}
|
||||
```
|
||||
|
||||
## Build Configuration
|
||||
|
||||
Web preview code compiles into the main `simplex-chat` library (not conditional). The thread only starts when `relayWebOptions` is set in `CoreChatOpts`. Mobile apps never set this.
|
||||
|
||||
No cabal flag needed - the thread startup is gated by `Maybe RelayWebOptions` at runtime (same pattern as `chatRelay` gating relay behavior).
|
||||
|
||||
## Caddy Setup (operator documentation)
|
||||
|
||||
Main Caddyfile (operator writes once):
|
||||
```caddy
|
||||
relay.example.com {
|
||||
import /etc/caddy/simplex-cors.conf
|
||||
handle /preview/* {
|
||||
root * /var/lib/simplex/web/preview
|
||||
file_server
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Relay CLI invocation:
|
||||
```
|
||||
simplex-chat --relay \
|
||||
--web-json-dir /var/lib/simplex/web/preview \
|
||||
--web-base-url https://relay.example.com/preview \
|
||||
--web-cors-file /etc/caddy/simplex-cors.conf \
|
||||
--web-update-interval 300
|
||||
```
|
||||
|
||||
## Channel Page and Embed Code
|
||||
|
||||
### Embed snippet (shown to owner)
|
||||
|
||||
The "Channel web page" screen auto-generates this from the channel's relay `baseWebUrl` values and `publicGroupId`. Owner copies it into their page:
|
||||
|
||||
```html
|
||||
<div id="simplex-channel"
|
||||
data-channel-id="<publicGroupId>"
|
||||
data-relays="<baseWebUrl1>,<baseWebUrl2>">
|
||||
</div>
|
||||
<script src="https://simplex.chat/channel-preview.js"></script>
|
||||
```
|
||||
|
||||
Example with real values:
|
||||
```html
|
||||
<div id="simplex-channel"
|
||||
data-channel-id="a1b2c3d4"
|
||||
data-relays="https://relay1.example.com/preview,https://relay2.example.com/preview">
|
||||
</div>
|
||||
<script src="https://simplex.chat/channel-preview.js"></script>
|
||||
```
|
||||
|
||||
The script fetches `<relay>/a1b2c3d4.json`, renders the preview into the `div`. Tries relays in order, falls back on failure. The owner's domain must match the CORS origin configured by the relay (derived from `groupWebPage`), or `allowEmbeding` must be `True` for `*`.
|
||||
|
||||
For iframe embedding (when allowed), the snippet is simpler - just an iframe pointing to the owner's hosted channel page.
|
||||
|
||||
### Channel page (static JS)
|
||||
|
||||
Separate repo or folder. `channel-preview.js` + minimal CSS:
|
||||
- Reads config from `data-` attributes on the container div
|
||||
- Fetches JSON from relays with fallback (try first, fall back to second)
|
||||
- Renders: channel header (name, avatar, description, subscriber count), message list (text with FormattedText markdown, link previews, file indicators, reactions, quotes)
|
||||
- Join button: `simplex://` deep link on mobile, QR code on desktop
|
||||
- Reuses directory page's markdown rendering approach
|
||||
|
||||
## Files to Create/Modify
|
||||
|
||||
### New files
|
||||
- `src/Simplex/Chat/Web/Preview.hs` - types: `WebChannelPreview`, `WebMessage`, `WebFileInfo`, `WebMemberProfile`
|
||||
- `src/Simplex/Chat/Web.hs` - render loop, JSON writing, Caddy config generation
|
||||
- `apps/multiplatform/.../views/chat/group/ChannelWebPageView.kt`
|
||||
- `apps/ios/Shared/Views/Chat/Group/ChannelWebPageView.swift`
|
||||
- Migration files (SQLite + Postgres): `group_web_page`, `group_domain`, `domain_web_page`, `allow_embedding`, `group_domain_verified_at` in group_profiles; `base_web_url` in group_relays
|
||||
- Channel page static site (separate repo/folder)
|
||||
|
||||
### Modified files
|
||||
- `src/Simplex/Chat/Types.hs` - `PublicGroupAccess` type, extend `PublicGroupProfile` with `publicGroupAccess`
|
||||
- `src/Simplex/Chat/Protocol.hs` - `RelayCapabilities` record, extend `XGrpRelayAcpt`, add `XGrpRelayCap`
|
||||
- `src/Simplex/Chat/Options.hs` - `RelayWebOptions` record, `relayWebOptions :: Maybe RelayWebOptions` in `CoreChatOpts`
|
||||
- `src/Simplex/Chat/Core.hs` - start web preview thread in `runSimplexChat`
|
||||
- `src/Simplex/Chat/Operators.hs` - `baseWebUrl` in `GroupRelay`
|
||||
- `src/Simplex/Chat/Store/Groups.hs` - read/write `PublicGroupAccess` columns; `getWebPublishGroups`
|
||||
- `src/Simplex/Chat/Store/Shared.hs` - `toPublicGroupAccess`, extend `toPublicGroupProfile` and `GroupInfoRow`
|
||||
- `src/Simplex/Chat/Library/Subscriber.hs` - handle `RelayCapabilities` in `XGrpRelayAcpt` and `XGrpRelayCap`
|
||||
- `apps/multiplatform/.../model/ChatModel.kt` - `PublicGroupAccess`, `RelayCapabilities`, `PublicGroupProfile.publicGroupAccess`, `GroupRelay.relayCap`
|
||||
- `apps/multiplatform/.../views/chat/group/GroupChatInfoView.kt` - nav link for web page
|
||||
- `simplex-chat.cabal` - add `Simplex.Chat.Web.Preview`, `Simplex.Chat.Web` to exposed-modules
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. **Data model** - `PublicGroupAccess` in `PublicGroupProfile`, migrations (separate columns), store functions
|
||||
2. **Protocol** - `RelayCapabilities`, extend `XGrpRelayAcpt`, add `XGrpRelayCap`, handlers in Subscriber.hs
|
||||
3. **CLI options** - `RelayWebOptions` record, `relayWebOptions` field in `CoreChatOpts`
|
||||
4. **Web types** - `WebChannelPreview`, `WebMessage`, etc. in new module
|
||||
5. **Render loop** - thread startup in Core.hs, periodic JSON generation, Caddy config
|
||||
6. **UI (owner)** - "Channel web page" settings screen
|
||||
7. **UI (subscriber)** - web page link in channel info
|
||||
8. **Channel page** - static HTML+JS template
|
||||
9. **Documentation** - operator setup guide
|
||||
|
||||
## Verification
|
||||
|
||||
1. **Build**: `cabal build simplex-chat` with new modules compiles
|
||||
2. **Unit test**: serialize `WebChannelPreview` with sample data, verify JSON matches expected structure
|
||||
3. **Integration test**: create channel with `publicGroupAccess` set, run relay with `--web-json-dir`, verify JSON file appears at correct path with correct content
|
||||
4. **CORS test**: verify generated config produces correct `Access-Control-Allow-Origin` for configured domains
|
||||
5. **UI test**: owner can set web page URL and domain, see embed snippet; subscriber sees clickable link
|
||||
6. **Channel page test**: serve static page locally against relay's JSON, verify rendering
|
||||
7. **Domain stripping test**: set `groupDomain` on a channel, verify it is stripped from web export JSON (unverified, `group_domain_verified_at IS NULL`)
|
||||
@@ -1,111 +0,0 @@
|
||||
# CLI terminal: event loss root cause analysis
|
||||
|
||||
## Two distinct problems
|
||||
|
||||
### Problem 1: Paste — TMVar capacity-1 bottleneck
|
||||
|
||||
When copy-pasting text, the capacity-1 `TMVar` event channel between the keyboard input reader and the consumer loop throttles stdin reading to terminal redraw speed.
|
||||
|
||||
**Root cause:** `events <- liftIO newEmptyTMVarIO` (`Platform.hsc:64`). Producer blocks on `putTMVar` after each event until consumer finishes redrawing. Consumer does a full terminal redraw per event (`Input.hs:161`).
|
||||
|
||||
**Fix:** Replace `TMVar` with `TQueue` in `Platform.hsc` (6 line changes on POSIX, matching changes on Windows). Decouples producer from consumer — stdin is drained at full speed regardless of redraw speed.
|
||||
|
||||
See previous analysis in git history for full details on this issue.
|
||||
|
||||
---
|
||||
|
||||
### Problem 2: Heavy load — `outputQ` backpressure blocks `agentSubscriber`
|
||||
|
||||
When the CLI is used as a heavy client (e.g., 1M connections), incoming chat events overwhelm the terminal display, causing cascading backpressure that blocks message acknowledgments and stalls the entire event processing pipeline.
|
||||
|
||||
**This is the more severe problem.** It causes actual message loss at the protocol level, not just UI slowness.
|
||||
|
||||
## Root cause: bounded `outputQ` + single-threaded `agentSubscriber`
|
||||
|
||||
### The queue chain
|
||||
|
||||
```
|
||||
Network (SMP/XFTP connections)
|
||||
→ agent internal queues
|
||||
→ subQ (TBQueue, capacity 1024) ← agent → chat boundary
|
||||
→ agentSubscriber (single-threaded) ← Commands.hs:4167
|
||||
→ processAgentMessage ← Subscriber.hs:109
|
||||
→ toView_ → writeTBQueue outputQ ← Controller.hs:1528, BLOCKS when full
|
||||
→ outputQ (TBQueue, capacity 1024) ← Chat.hs:152
|
||||
→ runTerminalOutput ← Output.hs:146
|
||||
→ printToTerminal (acquires termLock) ← Output.hs:298-303
|
||||
→ terminal I/O (slow)
|
||||
```
|
||||
|
||||
All queues are bounded `TBQueue` with default capacity 1024 (`Options.hs:226`). All writes use `writeTBQueue` which **blocks when full** — no events are dropped within the application, but backpressure cascades upstream.
|
||||
|
||||
### The blocking chain under heavy load
|
||||
|
||||
1. **Terminal I/O is the bottleneck.** `runTerminalOutput` (`Output.hs:146`) reads one event at a time from `outputQ`, acquires `termLock`, prints the message + redraws input, releases lock. Each iteration involves ANSI escape sequences, cursor manipulation, and `flush` syscalls. Throughput: ~hundreds of events/sec at best.
|
||||
|
||||
2. **`outputQ` fills up.** With 1M connections generating events, the arrival rate far exceeds terminal display speed. The 1024-element TBQueue fills in seconds.
|
||||
|
||||
3. **`toView_` blocks.** `Controller.hs:1528`: `writeTBQueue localQ (Nothing, event)` blocks when the queue is full. This call happens inside `processAgentMessage` → `processAgentMessageConn`, which runs within the `agentSubscriber` loop.
|
||||
|
||||
4. **`agentSubscriber` blocks — head-of-line blocking.** `Commands.hs:4164-4167`:
|
||||
```haskell
|
||||
agentSubscriber = do
|
||||
q <- asks $ subQ . smpAgent
|
||||
forever (atomically (readTBQueue q) >>= process)
|
||||
```
|
||||
Single-threaded. When `process` blocks on `toView_`, ALL events for ALL connections queue up behind it. Events for 1M other connections — including time-critical ACKs, keepalives, and handshakes — are stuck.
|
||||
|
||||
5. **ACKs are never sent.** The message receive path (`Subscriber.hs:1537-1540`) calls `toView` BEFORE `ackMsg`:
|
||||
```haskell
|
||||
-- Inside withAckMessage's action:
|
||||
saveRcvChatItem' ... -- save to DB (succeeds)
|
||||
toView $ CEvtNewChatItems ... -- BLOCKS here (outputQ full)
|
||||
-- returns (withRcpt, shouldDelConns)
|
||||
|
||||
-- After action returns (Subscriber.hs:1396-1397):
|
||||
ackMsg msgMeta ... -- NEVER REACHED while toView blocks
|
||||
```
|
||||
The developers explicitly acknowledge this at `Subscriber.hs:122-123`:
|
||||
> *without ACK the message delivery will be stuck*
|
||||
|
||||
6. **`subQ` fills up.** The agent can't deliver events to `subQ` (also capacity 1024) because `agentSubscriber` isn't reading. Agent-level processing stalls.
|
||||
|
||||
7. **Network-level failure.** Connections time out due to unprocessed keepalives and unacknowledged messages. Messages are lost at the protocol level.
|
||||
|
||||
### `termLock` contention worsens the bottleneck
|
||||
|
||||
`termLock` (`Output.hs:55`) is a `TMVar ()` mutex shared between:
|
||||
- **Output thread** (`runTerminalOutput` → `printToTerminal`): acquires lock for each displayed message
|
||||
- **Input thread** (`receiveFromTTY` → `updateInput`): acquires lock after each keystroke
|
||||
- **Live prompt thread** (`blinkLivePrompt` → `updateInputView`): acquires lock every 1 second
|
||||
|
||||
Under heavy load, the output thread dominates the lock (constant stream of messages). The input thread is starved — user keystrokes are delayed. This also slows the output thread itself (lock contention overhead).
|
||||
|
||||
Note: `withTermLock` (`Output.hs:138-142`) is not exception-safe — no `bracket`/`finally`. If the action throws, the lock leaks and all threads deadlock.
|
||||
|
||||
### Error reporting also blocks
|
||||
|
||||
When `processAgentMessage` encounters an error, the error handler (`Commands.hs:4179`) calls `eToView'` → `toView_` → `writeTBQueue outputQ`. If `outputQ` is already full, even error reporting blocks. There is no escape path.
|
||||
|
||||
## Impact summary
|
||||
|
||||
| Load level | `outputQ` state | Effect |
|
||||
|---|---|---|
|
||||
| Light (few connections) | Nearly empty | No issues |
|
||||
| Moderate (hundreds) | Partially filled | Occasional display lag |
|
||||
| Heavy (thousands+) | Full (1024) | `toView_` blocks → `agentSubscriber` blocks → head-of-line blocking for ALL connections → ACKs delayed → message delivery stuck |
|
||||
| Extreme (1M connections) | Permanently full | Cascading failure: all event processing stops, connections time out, messages lost at protocol level |
|
||||
|
||||
## Fix
|
||||
|
||||
The core fix: **`toView_` must never block the event processing pipeline on terminal display.**
|
||||
|
||||
Options (in order of simplicity):
|
||||
|
||||
1. **Make `outputQ` unbounded** — replace `TBQueue` with `TQueue` in `Chat.hs:152`. `writeTQueue` never blocks. Events accumulate in memory under heavy load but the event processing pipeline (including ACKs) is never stalled. Tradeoff: unbounded memory growth under sustained heavy load.
|
||||
|
||||
2. **Non-blocking write with drop** — use `tryWriteTBQueue` in `toView_`. When `outputQ` is full, drop the display event (or a coalesced summary). ACKs and network processing proceed unblocked. Tradeoff: some events not displayed, but none lost at protocol level.
|
||||
|
||||
3. **Separate ACK from display** — restructure `withAckMessage` to send ACK immediately after DB save, before `toView`. This decouples protocol correctness from display. `toView` can still block, but ACKs are always timely. Tradeoff: requires careful restructuring of the message processing path.
|
||||
|
||||
4. **Increase queue capacity** — increase `tbqSize` from 1024 to a larger value. Delays the problem but doesn't fix it. Under sustained heavy load, any finite queue eventually fills.
|
||||
@@ -133,6 +133,7 @@ library
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260515_public_group_access
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260529_delivery_job_senders
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services
|
||||
Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at
|
||||
@@ -291,6 +292,7 @@ library
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260515_public_group_access
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260529_delivery_job_senders
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services
|
||||
Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at
|
||||
|
||||
@@ -2533,7 +2533,8 @@ processChatCommand vr nm = \case
|
||||
-- generate owner key, OwnerAuth signed by root key
|
||||
memberId <- MemberId <$> liftIO (encodedRandomBytes gVar 12)
|
||||
(memberPrivKey, ownerAuth) <- liftIO $ SL.newOwnerAuth gVar (unMemberId memberId) rootPrivKey
|
||||
let groupProfile' = (groupProfile :: GroupProfile) {publicGroup = Just PublicGroupProfile {groupType = GTChannel, groupLink = sLnk, publicGroupId = B64UrlByteString entityId}}
|
||||
-- TODO [channel web] pass publicGroupAccess from owner's profile
|
||||
let groupProfile' = (groupProfile :: GroupProfile) {publicGroup = Just PublicGroupProfile {groupType = GTChannel, groupLink = sLnk, publicGroupId = B64UrlByteString entityId, publicGroupAccess = Nothing}}
|
||||
userData = encodeShortLinkData $ GroupShortLinkData {groupProfile = groupProfile', publicGroupData = Just (PublicGroupData 1)}
|
||||
userLinkData = UserContactLinkData UserContactData {direct = False, owners = [ownerAuth], relays = [], userData}
|
||||
-- create connection with prepared link (single network call)
|
||||
@@ -2651,8 +2652,7 @@ processChatCommand vr nm = \case
|
||||
Nothing -> throwChatError $ CEContactNotActive ct
|
||||
APIAcceptMember groupId gmId role -> withUser $ \user@User {userId} -> do
|
||||
(gInfo, m) <- withFastStore $ \db -> (,) <$> getGroupInfo db vr user groupId <*> getGroupMemberById db vr user gmId
|
||||
-- TODO check that user's role is > role, possibly restrict role to only observer and member
|
||||
assertUserGroupRole gInfo GRModerator
|
||||
assertUserGroupRole gInfo $ max GRModerator role
|
||||
case memberStatus m of
|
||||
GSMemPendingApproval | memberCategory m == GCInviteeMember -> do -- only host can approve
|
||||
let GroupInfo {groupProfile = GroupProfile {memberAdmission}} = gInfo
|
||||
|
||||
@@ -919,13 +919,15 @@ acceptContactRequestAsync
|
||||
let profileToSend = userProfileDirect user (fromIncognitoProfile <$> incognitoProfile) (Just ct) True
|
||||
vr <- chatVersionRange
|
||||
let chatV = vr `peerConnChatVersion` cReqChatVRange
|
||||
(cmdId, acId) <- agentAcceptContactAsync user True cReqInvId (XInfo profileToSend) subMode cReqPQSup chatV
|
||||
(cmdId, acId) <- prepareAgentAccept user True cReqInvId cReqPQSup
|
||||
currentTs <- liftIO getCurrentTime
|
||||
withStore $ \db -> do
|
||||
ct' <- withStore $ \db -> do
|
||||
forM_ xContactId $ \xcId -> liftIO $ setContactAcceptedXContactId db ct xcId
|
||||
Connection {connId} <- liftIO $ createAcceptedContactConn db user (Just uclId) contactId acId chatV cReqChatVRange cReqPQSup incognitoProfile subMode currentTs
|
||||
liftIO $ setCommandConnId db user cmdId connId
|
||||
getContact db vr user contactId
|
||||
agentAcceptContactAsync user cmdId acId True cReqInvId (XInfo profileToSend) cReqPQSup chatV subMode
|
||||
pure ct'
|
||||
|
||||
acceptGroupJoinRequestAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> Maybe MemberId -> Maybe SharedMsgId -> GroupAcceptance -> GroupMemberRole -> Maybe IncognitoProfile -> Maybe MemberKey -> CM GroupMember
|
||||
acceptGroupJoinRequestAsync
|
||||
@@ -963,10 +965,11 @@ acceptGroupJoinRequestAsync
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
vr <- chatVersionRange
|
||||
let chatV = vr `peerConnChatVersion` cReqChatVRange
|
||||
connIds <- agentAcceptContactAsync user True cReqInvId msg subMode PQSupportOff chatV
|
||||
withStore $ \db -> do
|
||||
liftIO $ createJoiningMemberConnection db user uclId connIds chatV cReqChatVRange groupMemberId subMode
|
||||
getGroupMemberById db vr user groupMemberId
|
||||
(cmdId, acId) <- prepareAgentAccept user True cReqInvId PQSupportOff
|
||||
withStore $ \db ->
|
||||
liftIO $ createJoiningMemberConnection db user uclId (cmdId, acId) chatV cReqChatVRange groupMemberId subMode
|
||||
agentAcceptContactAsync user cmdId acId True cReqInvId msg PQSupportOff chatV subMode
|
||||
withStore $ \db -> getGroupMemberById db vr user groupMemberId
|
||||
|
||||
acceptGroupJoinSendRejectAsync :: User -> Int64 -> GroupInfo -> InvitationId -> VersionRangeChat -> Profile -> Maybe XContactId -> GroupRejectionReason -> CM GroupMember
|
||||
acceptGroupJoinSendRejectAsync
|
||||
@@ -993,10 +996,11 @@ acceptGroupJoinSendRejectAsync
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
vr <- chatVersionRange
|
||||
let chatV = vr `peerConnChatVersion` cReqChatVRange
|
||||
connIds <- agentAcceptContactAsync user False cReqInvId msg subMode PQSupportOff chatV
|
||||
withStore $ \db -> do
|
||||
liftIO $ createJoiningMemberConnection db user uclId connIds chatV cReqChatVRange groupMemberId subMode
|
||||
getGroupMemberById db vr user groupMemberId
|
||||
(cmdId, acId) <- prepareAgentAccept user False cReqInvId PQSupportOff
|
||||
withStore $ \db ->
|
||||
liftIO $ createJoiningMemberConnection db user uclId (cmdId, acId) chatV cReqChatVRange groupMemberId subMode
|
||||
agentAcceptContactAsync user cmdId acId False cReqInvId msg PQSupportOff chatV subMode
|
||||
withStore $ \db -> getGroupMemberById db vr user groupMemberId
|
||||
|
||||
acceptBusinessJoinRequestAsync :: User -> Int64 -> GroupInfo -> GroupMember -> UserContactRequest -> CM (GroupInfo, GroupMember)
|
||||
acceptBusinessJoinRequestAsync
|
||||
@@ -1025,10 +1029,11 @@ acceptBusinessJoinRequestAsync
|
||||
}
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
let chatV = vr `peerConnChatVersion` cReqChatVRange
|
||||
connIds <- agentAcceptContactAsync user True cReqInvId msg subMode PQSupportOff chatV
|
||||
(cmdId, acId) <- prepareAgentAccept user True cReqInvId PQSupportOff
|
||||
withStore' $ \db -> do
|
||||
forM_ xContactId $ \xcId -> setBusinessChatAcceptedXContactId db gInfo xcId
|
||||
createJoiningMemberConnection db user uclId connIds chatV cReqChatVRange groupMemberId subMode
|
||||
createJoiningMemberConnection db user uclId (cmdId, acId) chatV cReqChatVRange groupMemberId subMode
|
||||
agentAcceptContactAsync user cmdId acId True cReqInvId msg PQSupportOff chatV subMode
|
||||
let cd = CDGroupSnd gInfo Nothing
|
||||
-- TODO [short links] move to profileContactRequest?
|
||||
createInternalChatItem user cd (CISndGroupE2EEInfo $ e2eInfoGroup gInfo) Nothing
|
||||
@@ -1045,16 +1050,19 @@ acceptRelayJoinRequestAsync
|
||||
cReqInvId
|
||||
cReqChatVRange
|
||||
relayLink = do
|
||||
let msg = XGrpRelayAcpt relayLink
|
||||
-- TODO [channel web] derive RelayCapabilities from relay config (RelayWebOptions)
|
||||
let msg = XGrpRelayAcpt relayLink defaultRelayCapabilities
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
vr <- chatVersionRange
|
||||
let chatV = vr `peerConnChatVersion` cReqChatVRange
|
||||
connIds <- agentAcceptContactAsync user True cReqInvId msg subMode PQSupportOff chatV
|
||||
withStore $ \db -> do
|
||||
liftIO $ createJoiningMemberConnection db user uclId connIds chatV cReqChatVRange groupMemberId subMode
|
||||
(cmdId, acId) <- prepareAgentAccept user True cReqInvId PQSupportOff
|
||||
r <- withStore $ \db -> do
|
||||
liftIO $ createJoiningMemberConnection db user uclId (cmdId, acId) chatV cReqChatVRange groupMemberId subMode
|
||||
gInfo' <- liftIO $ updateRelayOwnStatusFromTo db gInfo RSInvited RSAccepted
|
||||
ownerMember' <- getGroupMemberById db vr user groupMemberId
|
||||
pure (gInfo', ownerMember')
|
||||
agentAcceptContactAsync user cmdId acId True cReqInvId msg PQSupportOff chatV subMode
|
||||
pure r
|
||||
|
||||
rejectRelayInvitationAsync
|
||||
:: User
|
||||
@@ -1074,9 +1082,10 @@ rejectRelayInvitationAsync user uclId vr groupRelayInv invId reqChatVRange initi
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
chatVR <- chatVersionRange
|
||||
let chatV = chatVR `peerConnChatVersion` reqChatVRange
|
||||
connIds <- agentAcceptContactAsync user False invId msg subMode PQSupportOff chatV
|
||||
(cmdId, acId) <- prepareAgentAccept user False invId PQSupportOff
|
||||
withStore' $ \db ->
|
||||
createJoiningMemberConnection db user uclId connIds chatV reqChatVRange groupMemberId subMode
|
||||
createJoiningMemberConnection db user uclId (cmdId, acId) chatV reqChatVRange groupMemberId subMode
|
||||
agentAcceptContactAsync user cmdId acId False invId msg PQSupportOff chatV subMode
|
||||
|
||||
businessGroupProfile :: Profile -> GroupPreferences -> GroupProfile
|
||||
businessGroupProfile Profile {displayName, fullName, shortDescr, image} groupPreferences =
|
||||
@@ -2478,18 +2487,28 @@ msgContentHasLink mc ft_ = case msgContentTag mc of
|
||||
MCLink_ -> True
|
||||
_ -> maybe False hasLinks ft_
|
||||
|
||||
createAgentConnectionAsync :: ConnectionModeI c => User -> CommandFunction -> Bool -> SConnectionMode c -> SubscriptionMode -> CM (CommandId, ConnId)
|
||||
createAgentConnectionAsync user cmdFunction enableNtfs cMode subMode = do
|
||||
prepareAgentCreation :: ConnectionModeI c => User -> CommandFunction -> Bool -> SConnectionMode c -> CM (CommandId, ConnId)
|
||||
prepareAgentCreation user cmdFunction enableNtfs cMode = do
|
||||
cmdId <- withStore' $ \db -> createCommand db user Nothing cmdFunction
|
||||
connId <- withAgent $ \a -> createConnectionAsync a (aUserId user) (aCorrId cmdId) enableNtfs cMode IKPQOff subMode
|
||||
connId <- withAgent $ \a -> prepareConnectionToCreate a (aUserId user) enableNtfs cMode PQSupportOff
|
||||
pure (cmdId, connId)
|
||||
|
||||
joinAgentConnectionAsync :: User -> Maybe Connection -> Bool -> ConnectionRequestUri c -> ConnInfo -> SubscriptionMode -> CM (CommandId, ConnId)
|
||||
joinAgentConnectionAsync user conn_ enableNtfs cReqUri cInfo subMode = do
|
||||
createAgentConnectionAsync :: ConnectionModeI c => User -> CommandId -> ConnId -> Bool -> SConnectionMode c -> SubscriptionMode -> CM ()
|
||||
createAgentConnectionAsync user cmdId connId enableNtfs cMode subMode =
|
||||
withAgent $ \a -> createConnectionAsync a (aCorrId cmdId) connId enableNtfs cMode IKPQOff subMode
|
||||
|
||||
prepareAgentJoin :: User -> Maybe Connection -> Bool -> ConnectionRequestUri c -> CM (CommandId, ConnId)
|
||||
prepareAgentJoin user conn_ enableNtfs cReqUri = do
|
||||
cmdId <- withStore' $ \db -> createCommand db user (dbConnId <$> conn_) CFJoinConn
|
||||
connId <- withAgent $ \a -> joinConnectionAsync a (aUserId user) (aCorrId cmdId) (aConnId <$> conn_) enableNtfs cReqUri cInfo PQSupportOff subMode
|
||||
connId <- case conn_ of
|
||||
Just conn -> pure $ aConnId conn
|
||||
Nothing -> withAgent $ \a -> prepareConnectionToJoin a (aUserId user) enableNtfs cReqUri PQSupportOff
|
||||
pure (cmdId, connId)
|
||||
|
||||
joinAgentConnectionAsync :: ConnectionModeI c => User -> CommandId -> Bool -> ConnId -> Bool -> ConnectionRequestUri c -> ConnInfo -> SubscriptionMode -> CM ()
|
||||
joinAgentConnectionAsync user cmdId updateConn connId enableNtfs cReqUri cInfo subMode =
|
||||
withAgent $ \a -> joinConnectionAsync a (aCorrId cmdId) updateConn connId enableNtfs cReqUri cInfo PQSupportOff subMode
|
||||
|
||||
allowAgentConnectionAsync :: MsgEncodingI e => User -> Connection -> ConfirmationId -> ChatMsgEvent e -> CM ()
|
||||
allowAgentConnectionAsync user conn@Connection {connId, pqSupport, connChatVersion} confId msg = do
|
||||
cmdId <- withStore' $ \db -> createCommand db user (Just connId) CFAllowConn
|
||||
@@ -2497,13 +2516,17 @@ allowAgentConnectionAsync user conn@Connection {connId, pqSupport, connChatVersi
|
||||
withAgent $ \a -> allowConnectionAsync a (aCorrId cmdId) (aConnId conn) confId dm
|
||||
withStore' $ \db -> updateConnectionStatus db conn ConnAccepted
|
||||
|
||||
agentAcceptContactAsync :: MsgEncodingI e => User -> Bool -> InvitationId -> ChatMsgEvent e -> SubscriptionMode -> PQSupport -> VersionChat -> CM (CommandId, ConnId)
|
||||
agentAcceptContactAsync user enableNtfs invId msg subMode pqSup chatV = do
|
||||
prepareAgentAccept :: User -> Bool -> InvitationId -> PQSupport -> CM (CommandId, ConnId)
|
||||
prepareAgentAccept user enableNtfs invId pqSup = do
|
||||
cmdId <- withStore' $ \db -> createCommand db user Nothing CFAcceptContact
|
||||
dm <- encodeConnInfoPQ pqSup chatV msg
|
||||
connId <- withAgent $ \a -> acceptContactAsync a (aUserId user) (aCorrId cmdId) enableNtfs invId dm pqSup subMode
|
||||
connId <- withAgent $ \a -> prepareConnectionToAccept a (aUserId user) enableNtfs invId pqSup
|
||||
pure (cmdId, connId)
|
||||
|
||||
agentAcceptContactAsync :: MsgEncodingI e => User -> CommandId -> ConnId -> Bool -> InvitationId -> ChatMsgEvent e -> PQSupport -> VersionChat -> SubscriptionMode -> CM ()
|
||||
agentAcceptContactAsync user cmdId connId enableNtfs invId msg pqSup chatV subMode = do
|
||||
dm <- encodeConnInfoPQ pqSup chatV msg
|
||||
withAgent $ \a -> acceptContactAsync a (aCorrId cmdId) connId enableNtfs invId dm pqSup subMode
|
||||
|
||||
deleteAgentConnectionAsync :: ConnId -> CM ()
|
||||
deleteAgentConnectionAsync acId = deleteAgentConnectionAsync' acId False
|
||||
{-# INLINE deleteAgentConnectionAsync #-}
|
||||
|
||||
@@ -627,9 +627,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
forM_ gli_ $ \GroupLinkInfo {groupId, memberRole = gLinkMemRole} -> do
|
||||
groupInfo <- withStore $ \db -> getGroupInfo db vr user groupId
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
groupConnIds <- createAgentConnectionAsync user CFCreateConnGrpInv True SCMInvitation subMode
|
||||
groupConnIds <- prepareAgentCreation user CFCreateConnGrpInv True SCMInvitation
|
||||
gVar <- asks random
|
||||
withStore $ \db -> createNewContactMemberAsync db gVar user groupInfo ct' gLinkMemRole groupConnIds connChatVersion peerChatVRange subMode
|
||||
uncurry (createAgentConnectionAsync user) groupConnIds True SCMInvitation subMode
|
||||
-- TODO REMOVE LEGACY ^^^
|
||||
SENT msgId proxy -> do
|
||||
void $ continueSending connEntity conn
|
||||
@@ -774,9 +775,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
-- [async agent commands] no continuation needed, but command should be asynchronous for stability
|
||||
allowAgentConnectionAsync user conn' confId XOk
|
||||
| otherwise -> messageError "x.grp.acpt: memberId is different from expected"
|
||||
XGrpRelayAcpt relayLink
|
||||
XGrpRelayAcpt relayLink relayCap
|
||||
| memberRole' membership == GROwner && isRelay m -> do
|
||||
withStore' $ \db -> setRelayLinkConfId db m confId relayLink
|
||||
withStore' $ \db -> do
|
||||
setRelayLinkConfId db m confId relayLink
|
||||
updateRelayCapabilities db m relayCap
|
||||
void $ getAgentConnShortLinkAsync user CFGetRelayDataAccept (Just conn') relayLink
|
||||
| otherwise -> messageError "x.grp.relay.acpt: only owner can add relay"
|
||||
XGrpRelayReject reason
|
||||
@@ -1045,6 +1048,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
XGrpLinkMem p -> Nothing <$ xGrpLinkMem gInfo' m'' conn' p
|
||||
XGrpLinkAcpt acceptance role memberId -> Nothing <$ xGrpLinkAcpt gInfo' m'' acceptance role memberId msg brokerTs
|
||||
XGrpRelayNew rl -> fmap ctx <$> xGrpRelayNew gInfo' m'' rl
|
||||
XGrpRelayCap relayCap
|
||||
| memberRole' membership == GROwner && isRelay m'' ->
|
||||
Nothing <$ withStore' (\db -> updateRelayCapabilities db m'' relayCap)
|
||||
| otherwise -> Nothing <$ messageWarning "x.grp.relay.cap: only owner should receive relay capabilities"
|
||||
XGrpMemNew memInfo msgScope -> fmap ctx <$> xGrpMemNew gInfo' m'' memInfo msgScope msg brokerTs
|
||||
XGrpMemIntro memInfo memRestrictions_ -> Nothing <$ xGrpMemIntro gInfo' m'' memInfo memRestrictions_
|
||||
XGrpMemInv memId introInv -> Nothing <$ xGrpMemInv gInfo' m'' memId introInv
|
||||
@@ -1193,7 +1200,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
Nothing -> throwChatError $ CEInternalError "no group keys for channel membership"
|
||||
dm <- encodeConnInfo $ XMember profileToSend membershipMemId (MemberKey memberPubKey)
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
void $ joinAgentConnectionAsync user (Just conn) True cReq dm subMode
|
||||
(cmdId, connId) <- prepareAgentJoin user (Just conn) True cReq
|
||||
joinAgentConnectionAsync user cmdId True connId True cReq dm subMode
|
||||
CFGetRelayDataAccept -> do
|
||||
let GroupMember {memberId = MemberId expectedMemberId} = m
|
||||
if linkEntityId == Just expectedMemberId
|
||||
@@ -1577,10 +1585,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
chatVR <- chatVersionRange
|
||||
let chatV = chatVR `peerConnChatVersion` chatVRange
|
||||
(cmdId, acId) <- agentAcceptContactAsync user True invId msg subMode PQSupportOff chatV
|
||||
(cmdId, acId) <- prepareAgentAccept user True invId PQSupportOff
|
||||
withStore $ \db -> do
|
||||
Connection {connId = testCId} <- createRelayTestConnection db vr user acId ConnAccepted chatV subMode
|
||||
liftIO $ setCommandConnId db user cmdId testCId
|
||||
agentAcceptContactAsync user cmdId acId True invId msg PQSupportOff chatV subMode
|
||||
-- TODO [relays] owner, relays: TBC how to communicate member rejection rules from owner to relays
|
||||
-- TODO [relays] relay: TBC communicate rejection when memberId already exists (currently checked in createJoiningMember)
|
||||
memberJoinRequestViaRelay :: InvitationId -> VersionRangeChat -> Profile -> MemberId -> MemberKey -> CM ()
|
||||
@@ -2501,12 +2510,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
then do
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
dm <- encodeConnInfo $ XGrpAcpt membershipMemId
|
||||
connIds <- joinAgentConnectionAsync user Nothing True connRequest dm subMode
|
||||
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 user 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
|
||||
@@ -2609,6 +2619,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
|
||||
xGrpLinkAcpt :: GroupInfo -> GroupMember -> GroupAcceptance -> GroupMemberRole -> MemberId -> RcvMessage -> UTCTime -> CM ()
|
||||
xGrpLinkAcpt gInfo@GroupInfo {membership} m acceptance role memberId msg brokerTs
|
||||
| memberRole' m < GRModerator || memberRole' m < role =
|
||||
messageError "x.grp.link.acpt with insufficient member permissions"
|
||||
| sameMemberId memberId membership = processUserAccepted
|
||||
| otherwise =
|
||||
withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case
|
||||
@@ -3039,15 +3051,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
| maxVersion mcvr >= groupDirectInvVersion -> do
|
||||
subMode <- chatReadVar subscriptionMode
|
||||
-- [async agent commands] commands should be asynchronous, continuation is to send XGrpMemInv - have to remember one has completed and process on second
|
||||
groupConnIds <- createConn subMode
|
||||
groupConnIds <- prepareConn
|
||||
let chatV = maybe (minVersion vr) (\peerVR -> vr `peerConnChatVersion` fromChatVRange peerVR) memChatVRange
|
||||
void $ withStore $ \db -> do
|
||||
reMember <- createIntroReMember db user gInfo memInfo memRestrictions
|
||||
createIntroReMemberConn db user m reMember chatV memInfo groupConnIds subMode
|
||||
uncurry (createAgentConnectionAsync user) groupConnIds (chatHasNtfs chatSettings) SCMInvitation subMode
|
||||
| otherwise -> messageError "x.grp.mem.intro: member chat version range incompatible"
|
||||
_ -> messageError "x.grp.mem.intro can be only sent by host member"
|
||||
where
|
||||
createConn subMode = createAgentConnectionAsync user CFCreateConnGrpMemInv (chatHasNtfs chatSettings) SCMInvitation subMode
|
||||
prepareConn = prepareAgentCreation user CFCreateConnGrpMemInv (chatHasNtfs chatSettings) SCMInvitation
|
||||
|
||||
sendXGrpMemInv :: Int64 -> Maybe ConnReqInvitation -> XGrpMemIntroCont -> CM ()
|
||||
sendXGrpMemInv hostConnId directConnReq XGrpMemIntroCont {groupId, groupMemberId, memberId, groupConnReq} = do
|
||||
@@ -3091,12 +3104,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
allowSimplexLinks = groupFeatureUserAllowed SGFSimplexLinks gInfo
|
||||
dm <- encodeConnInfo $ XGrpMemInfo membershipMemId membershipProfile
|
||||
-- [async agent commands] no continuation needed, but commands should be asynchronous for stability
|
||||
groupConnIds <- joinAgentConnectionAsync user Nothing (chatHasNtfs chatSettings) groupConnReq dm subMode
|
||||
directConnIds <- forM directConnReq $ \dcr -> joinAgentConnectionAsync user Nothing True dcr dm subMode
|
||||
let enableNtfsGrp = chatHasNtfs chatSettings
|
||||
groupConnIds@(gCmdId, gAcId) <- prepareAgentJoin user Nothing enableNtfsGrp groupConnReq
|
||||
directConnIds <- forM directConnReq $ \dcr -> prepareAgentJoin user Nothing True dcr
|
||||
let customUserProfileId = localProfileId <$> incognitoMembershipProfile gInfo
|
||||
mcvr = maybe chatInitialVRange fromChatVRange memChatVRange
|
||||
chatV = vr `peerConnChatVersion` mcvr
|
||||
withStore' $ \db -> createIntroToMemberContact db user m toMember chatV mcvr groupConnIds directConnIds customUserProfileId subMode
|
||||
joinAgentConnectionAsync user gCmdId False gAcId enableNtfsGrp groupConnReq dm subMode
|
||||
forM_ ((,) <$> directConnIds <*> directConnReq) $ \((dCmdId, dAcId), dcr) ->
|
||||
joinAgentConnectionAsync user dCmdId False dAcId True dcr dm subMode
|
||||
|
||||
xGrpMemRole :: GroupInfo -> GroupMember -> MemberId -> GroupMemberRole -> RcvMessage -> UTCTime -> CM (Maybe DeliveryJobScope)
|
||||
xGrpMemRole gInfo@GroupInfo {membership} m@GroupMember {memberRole = senderRole} memId memRole msg@RcvMessage {msgSigned} brokerTs
|
||||
@@ -3351,11 +3368,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
}
|
||||
joinExistingContact subMode mCt@Contact {contactId = mContactId}
|
||||
| autoAcceptMemberContacts user = do
|
||||
(cmdId, acId) <- joinConn subMode
|
||||
(cmdId, acId) <- prepareJoinMemberContact
|
||||
mCt' <- withStore $ \db -> do
|
||||
updateMemberContactInvited db user mCt groupDirectInv
|
||||
void $ liftIO $ createMemberContactConn db user acId (Just cmdId) g mConn ConnJoined mContactId subMode
|
||||
getContact db vr user mContactId
|
||||
joinMemberContactAsync cmdId acId subMode
|
||||
securityCodeChanged mCt'
|
||||
createItems mCt' m
|
||||
| otherwise = do
|
||||
@@ -3369,13 +3387,14 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
createItems mCt' m
|
||||
createNewContact subMode
|
||||
| autoAcceptMemberContacts user = do
|
||||
(cmdId, acId) <- joinConn subMode
|
||||
(cmdId, acId) <- prepareJoinMemberContact
|
||||
-- [incognito] reuse membership incognito profile
|
||||
(mCt, m') <- withStore $ \db -> do
|
||||
(mContactId, m') <- liftIO $ createMemberContactInvited db user g m groupDirectInv
|
||||
void $ liftIO $ createMemberContactConn db user acId (Just cmdId) g mConn ConnJoined mContactId subMode
|
||||
mCt <- getContact db vr user mContactId
|
||||
pure (mCt, m')
|
||||
joinMemberContactAsync cmdId acId subMode
|
||||
createInternalChatItem user (CDDirectSnd mCt) CIChatBanner (Just epochStart)
|
||||
createItems mCt m'
|
||||
| otherwise = do
|
||||
@@ -3388,12 +3407,13 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage =
|
||||
createInternalChatItem user (CDDirectSnd mCt) CIChatBanner (Just epochStart)
|
||||
createInternalChatItem user (CDDirectRcv mCt) (CIRcvDirectEvent $ RDEGroupInvLinkReceived gp) Nothing
|
||||
createItems mCt m'
|
||||
joinConn subMode = do
|
||||
prepareJoinMemberContact = prepareAgentJoin user Nothing True connReq
|
||||
joinMemberContactAsync cmdId acId subMode = do
|
||||
-- [incognito] send membership incognito profile
|
||||
let p = userProfileDirect user (fromLocalProfile <$> incognitoMembershipProfile g) Nothing True
|
||||
-- TODO PQ should negotitate contact connection with PQSupportOn? (use encodeConnInfoPQ)
|
||||
dm <- encodeConnInfo $ XInfo p
|
||||
joinAgentConnectionAsync user Nothing True connReq dm subMode
|
||||
joinAgentConnectionAsync user cmdId False acId True connReq dm subMode
|
||||
createItems mCt' m' = do
|
||||
(g', m'', scopeInfo) <- mkGroupChatScope g m'
|
||||
createInternalChatItem user (CDGroupRcv g' scopeInfo m'') (CIRcvGroupEvent RGEMemberCreatedContact) Nothing
|
||||
|
||||
@@ -46,7 +46,7 @@ import Data.Time (addUTCTime)
|
||||
import Data.Time.Clock (UTCTime, nominalDay)
|
||||
import Language.Haskell.TH.Syntax (lift)
|
||||
import Simplex.Chat.Operators.Conditions
|
||||
import Simplex.Chat.Protocol (RelayProfile (..))
|
||||
import Simplex.Chat.Protocol (RelayCapabilities (..), RelayProfile (..))
|
||||
import Simplex.Chat.Types (ShortLinkContact, User)
|
||||
import Simplex.Chat.Types.Shared (RelayStatus)
|
||||
import Simplex.Messaging.Agent.Env.SQLite (ServerCfg (..), ServerRoles (..), allRoles)
|
||||
@@ -280,7 +280,8 @@ data GroupRelay = GroupRelay
|
||||
groupMemberId :: Int64,
|
||||
userChatRelay :: UserChatRelay,
|
||||
relayStatus :: RelayStatus,
|
||||
relayLink :: Maybe ShortLinkContact
|
||||
relayLink :: Maybe ShortLinkContact,
|
||||
relayCap :: RelayCapabilities
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
|
||||
@@ -262,6 +262,14 @@ data LinkContent = LCPage | LCImage | LCVideo {duration :: Maybe Int} | LCUnknow
|
||||
data ReportReason = RRSpam | RRContent | RRCommunity | RRProfile | RROther | RRUnknown Text
|
||||
deriving (Eq, Show)
|
||||
|
||||
data RelayCapabilities = RelayCapabilities
|
||||
{ baseWebUrl :: Maybe Text
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
defaultRelayCapabilities :: RelayCapabilities
|
||||
defaultRelayCapabilities = RelayCapabilities {baseWebUrl = Nothing}
|
||||
|
||||
$(pure [])
|
||||
|
||||
instance FromJSON LinkContent where
|
||||
@@ -281,6 +289,12 @@ instance ToJSON LinkContent where
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''LinkPreview)
|
||||
|
||||
$(JQ.deriveToJSON defaultJSON ''RelayCapabilities)
|
||||
|
||||
instance FromJSON RelayCapabilities where
|
||||
parseJSON = $(JQ.mkParseJSON defaultJSON ''RelayCapabilities)
|
||||
omittedField = Just defaultRelayCapabilities
|
||||
|
||||
instance StrEncoding ReportReason where
|
||||
strEncode = \case
|
||||
RRSpam -> "spam"
|
||||
@@ -441,10 +455,11 @@ data ChatMsgEvent (e :: MsgEncoding) where
|
||||
XGrpLinkMem :: Profile -> ChatMsgEvent 'Json
|
||||
XGrpLinkAcpt :: GroupAcceptance -> GroupMemberRole -> MemberId -> ChatMsgEvent 'Json
|
||||
XGrpRelayInv :: GroupRelayInvitation -> ChatMsgEvent 'Json
|
||||
XGrpRelayAcpt :: ShortLinkContact -> ChatMsgEvent 'Json
|
||||
XGrpRelayAcpt :: ShortLinkContact -> RelayCapabilities -> ChatMsgEvent 'Json
|
||||
XGrpRelayTest :: ByteString -> Maybe ByteString -> ChatMsgEvent 'Json
|
||||
XGrpRelayNew :: ShortLinkContact -> ChatMsgEvent 'Json
|
||||
XGrpRelayReject :: RelayRejectionReason -> ChatMsgEvent 'Json
|
||||
XGrpRelayCap :: RelayCapabilities -> ChatMsgEvent 'Json
|
||||
XGrpMemNew :: MemberInfo -> Maybe MsgScope -> ChatMsgEvent 'Json
|
||||
XGrpMemIntro :: MemberInfo -> Maybe MemberRestrictions -> ChatMsgEvent 'Json
|
||||
XGrpMemInv :: MemberId -> IntroInvitation -> ChatMsgEvent 'Json
|
||||
@@ -991,6 +1006,7 @@ data CMEventTag (e :: MsgEncoding) where
|
||||
XGrpRelayTest_ :: CMEventTag 'Json
|
||||
XGrpRelayNew_ :: CMEventTag 'Json
|
||||
XGrpRelayReject_ :: CMEventTag 'Json
|
||||
XGrpRelayCap_ :: CMEventTag 'Json
|
||||
XGrpMemNew_ :: CMEventTag 'Json
|
||||
XGrpMemIntro_ :: CMEventTag 'Json
|
||||
XGrpMemInv_ :: CMEventTag 'Json
|
||||
@@ -1050,6 +1066,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where
|
||||
XGrpRelayTest_ -> "x.grp.relay.test"
|
||||
XGrpRelayNew_ -> "x.grp.relay.new"
|
||||
XGrpRelayReject_ -> "x.grp.relay.reject"
|
||||
XGrpRelayCap_ -> "x.grp.relay.cap"
|
||||
XGrpMemNew_ -> "x.grp.mem.new"
|
||||
XGrpMemIntro_ -> "x.grp.mem.intro"
|
||||
XGrpMemInv_ -> "x.grp.mem.inv"
|
||||
@@ -1110,6 +1127,7 @@ instance StrEncoding ACMEventTag where
|
||||
"x.grp.relay.test" -> XGrpRelayTest_
|
||||
"x.grp.relay.new" -> XGrpRelayNew_
|
||||
"x.grp.relay.reject" -> XGrpRelayReject_
|
||||
"x.grp.relay.cap" -> XGrpRelayCap_
|
||||
"x.grp.mem.new" -> XGrpMemNew_
|
||||
"x.grp.mem.intro" -> XGrpMemIntro_
|
||||
"x.grp.mem.inv" -> XGrpMemInv_
|
||||
@@ -1162,10 +1180,11 @@ toCMEventTag msg = case msg of
|
||||
XGrpLinkMem _ -> XGrpLinkMem_
|
||||
XGrpLinkAcpt {} -> XGrpLinkAcpt_
|
||||
XGrpRelayInv _ -> XGrpRelayInv_
|
||||
XGrpRelayAcpt _ -> XGrpRelayAcpt_
|
||||
XGrpRelayAcpt {} -> XGrpRelayAcpt_
|
||||
XGrpRelayTest {} -> XGrpRelayTest_
|
||||
XGrpRelayNew _ -> XGrpRelayNew_
|
||||
XGrpRelayReject _ -> XGrpRelayReject_
|
||||
XGrpRelayCap _ -> XGrpRelayCap_
|
||||
XGrpMemNew {} -> XGrpMemNew_
|
||||
XGrpMemIntro _ _ -> XGrpMemIntro_
|
||||
XGrpMemInv _ _ -> XGrpMemInv_
|
||||
@@ -1316,7 +1335,8 @@ appJsonToCM AppMessageJson {v, msgId, event, params} = do
|
||||
XGrpLinkMem_ -> XGrpLinkMem <$> p "profile"
|
||||
XGrpLinkAcpt_ -> XGrpLinkAcpt <$> p "acceptance" <*> p "role" <*> p "memberId"
|
||||
XGrpRelayInv_ -> XGrpRelayInv <$> p "groupRelayInvitation"
|
||||
XGrpRelayAcpt_ -> XGrpRelayAcpt <$> p "relayLink"
|
||||
XGrpRelayAcpt_ -> XGrpRelayAcpt <$> p "relayLink" <*> (fromMaybe defaultRelayCapabilities <$> opt "relayCap")
|
||||
XGrpRelayCap_ -> XGrpRelayCap <$> p "relayCap"
|
||||
XGrpRelayTest_ -> do
|
||||
B64UrlByteString challenge <- p "challenge"
|
||||
sig_ <- fmap (\(B64UrlByteString s) -> s) <$> opt "signature"
|
||||
@@ -1388,7 +1408,8 @@ chatToAppMessage chatMsg@ChatMessage {chatVRange, msgId, chatMsgEvent} = case en
|
||||
XGrpLinkMem profile -> o ["profile" .= profile]
|
||||
XGrpLinkAcpt acceptance role memberId -> o ["acceptance" .= acceptance, "role" .= role, "memberId" .= memberId]
|
||||
XGrpRelayInv groupRelayInv -> o ["groupRelayInvitation" .= groupRelayInv]
|
||||
XGrpRelayAcpt relayLink -> o ["relayLink" .= relayLink]
|
||||
XGrpRelayAcpt relayLink relayCap -> o ["relayLink" .= relayLink, "relayCap" .= relayCap]
|
||||
XGrpRelayCap relayCap -> o ["relayCap" .= relayCap]
|
||||
XGrpRelayTest challenge sig_ -> o $
|
||||
("signature" .=? (B64UrlByteString <$> sig_))
|
||||
["challenge" .= B64UrlByteString challenge]
|
||||
|
||||
@@ -139,6 +139,7 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do
|
||||
SELECT
|
||||
-- GroupInfo
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id,
|
||||
gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding,
|
||||
g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission,
|
||||
g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at,
|
||||
g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id,
|
||||
|
||||
@@ -91,6 +91,7 @@ module Simplex.Chat.Store.Groups
|
||||
updateRelayStatusFromTo,
|
||||
setRelayLinkAccepted,
|
||||
setRelayLinkConfId,
|
||||
updateRelayCapabilities,
|
||||
getRelayConfId,
|
||||
updateRelayMemberData,
|
||||
setGroupInProgressDone,
|
||||
@@ -370,10 +371,11 @@ createNewGroup db vr user@User {userId} groupProfile incognitoProfile useRelays
|
||||
INSERT INTO group_profiles
|
||||
(display_name, full_name, short_descr, description, image,
|
||||
group_type, group_link, public_group_id,
|
||||
group_web_page, group_domain, domain_web_page, allow_embedding,
|
||||
user_id, preferences, member_admission, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
|]
|
||||
((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_)
|
||||
((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. publicGroupAccessRow publicGroup
|
||||
:. (userId, groupPreferences, memberAdmission, currentTs, currentTs))
|
||||
profileId <- insertedRowId db
|
||||
DB.execute
|
||||
@@ -871,10 +873,11 @@ createGroup_ db userId groupProfile prepared business useRelays relayOwnStatus p
|
||||
INSERT INTO group_profiles
|
||||
(display_name, full_name, short_descr, description, image,
|
||||
group_type, group_link, public_group_id,
|
||||
group_web_page, group_domain, domain_web_page, allow_embedding,
|
||||
user_id, preferences, member_admission, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
|]
|
||||
((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_)
|
||||
((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. publicGroupAccessRow publicGroup
|
||||
:. (userId, groupPreferences, memberAdmission, currentTs, currentTs))
|
||||
profileId <- insertedRowId db
|
||||
DB.execute
|
||||
@@ -1362,15 +1365,16 @@ groupRelayQuery =
|
||||
[sql|
|
||||
SELECT gr.group_relay_id, gr.group_member_id,
|
||||
cr.chat_relay_id, cr.address, cr.display_name, cr.full_name, cr.short_descr, cr.image, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted,
|
||||
gr.relay_status, gr.relay_link
|
||||
gr.relay_status, gr.relay_link, gr.base_web_url
|
||||
FROM group_relays gr
|
||||
JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id
|
||||
|]
|
||||
|
||||
toGroupRelay :: (Int64, GroupMemberId, DBEntityId, ShortLinkContact, Text, Text, Maybe Text, Maybe ImageData, Text, BoolInt) :. (Maybe BoolInt, BoolInt, BoolInt, RelayStatus, Maybe ShortLinkContact) -> GroupRelay
|
||||
toGroupRelay ((groupRelayId, groupMemberId, chatRelayId, address, displayName, fullName, shortDescr, image, domains, BI preset) :. (tested, BI enabled, BI deleted, relayStatus, relayLink)) =
|
||||
toGroupRelay :: (Int64, GroupMemberId, DBEntityId, ShortLinkContact, Text, Text, Maybe Text, Maybe ImageData, Text, BoolInt) :. (Maybe BoolInt, BoolInt, BoolInt, RelayStatus, Maybe ShortLinkContact, Maybe Text) -> GroupRelay
|
||||
toGroupRelay ((groupRelayId, groupMemberId, chatRelayId, address, displayName, fullName, shortDescr, image, domains, BI preset) :. (tested, BI enabled, BI deleted, relayStatus, relayLink, baseWebUrl)) =
|
||||
let userChatRelay = UserChatRelay {chatRelayId, address, relayProfile = toRelayProfile (displayName, fullName, shortDescr, image), domains = T.splitOn "," domains, preset, tested = unBI <$> tested, enabled, deleted}
|
||||
in GroupRelay {groupRelayId, groupMemberId, userChatRelay, relayStatus, relayLink}
|
||||
relayCap = RelayCapabilities {baseWebUrl}
|
||||
in GroupRelay {groupRelayId, groupMemberId, userChatRelay, relayStatus, relayLink, relayCap}
|
||||
|
||||
createRelayForOwner :: DB.Connection -> VersionRangeChat -> TVar ChaChaDRG -> User -> GroupInfo -> UserChatRelay -> ExceptT StoreError IO GroupMember
|
||||
createRelayForOwner db vr gVar user@User {userId, userContactId} GroupInfo {groupId, membership} UserChatRelay {relayProfile = RelayProfile {displayName}} = do
|
||||
@@ -1510,6 +1514,18 @@ setRelayLinkConfId db m confId relayLink = do
|
||||
|]
|
||||
(relayLink, currentTs, groupMemberId' m)
|
||||
|
||||
updateRelayCapabilities :: DB.Connection -> GroupMember -> RelayCapabilities -> IO ()
|
||||
updateRelayCapabilities db m RelayCapabilities {baseWebUrl} = do
|
||||
currentTs <- getCurrentTime
|
||||
DB.execute
|
||||
db
|
||||
[sql|
|
||||
UPDATE group_relays
|
||||
SET base_web_url = ?, updated_at = ?
|
||||
WHERE group_member_id = ?
|
||||
|]
|
||||
(baseWebUrl, currentTs, groupMemberId' m)
|
||||
|
||||
getRelayConfId :: DB.Connection -> GroupMember -> ExceptT StoreError IO ConfirmationId
|
||||
getRelayConfId db m =
|
||||
ExceptT . firstRow fromOnly (SEGroupRelayNotFoundByMemberId $ groupMemberId' m) $
|
||||
@@ -2358,6 +2374,7 @@ updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName,
|
||||
UPDATE group_profiles
|
||||
SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?,
|
||||
group_type = ?, group_link = ?,
|
||||
group_web_page = ?, group_domain = ?, domain_web_page = ?, allow_embedding = ?,
|
||||
preferences = ?, member_admission = ?, updated_at = ?
|
||||
WHERE group_profile_id IN (
|
||||
SELECT group_profile_id
|
||||
@@ -2365,7 +2382,7 @@ updateGroupProfile db user@User {userId} g@GroupInfo {groupId, localDisplayName,
|
||||
WHERE user_id = ? AND group_id = ?
|
||||
)
|
||||
|]
|
||||
((newName, fullName, shortDescr, description, image, groupType_, groupLink_) :. (groupPreferences, memberAdmission, currentTs, userId, groupId))
|
||||
((newName, fullName, shortDescr, description, image, groupType_, groupLink_) :. publicGroupAccessRow publicGroup :. (groupPreferences, memberAdmission, currentTs, userId, groupId))
|
||||
updateGroup_ ldn currentTs = do
|
||||
DB.execute
|
||||
db
|
||||
@@ -2405,14 +2422,16 @@ updateGroupProfileFromMember db user g@GroupInfo {groupId} Profile {displayName
|
||||
[sql|
|
||||
SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image,
|
||||
gp.group_type, gp.group_link, gp.public_group_id,
|
||||
gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding,
|
||||
gp.preferences, gp.member_admission
|
||||
FROM group_profiles gp
|
||||
JOIN groups g ON gp.group_profile_id = g.group_profile_id
|
||||
WHERE g.group_id = ?
|
||||
|]
|
||||
(Only groupId)
|
||||
toGroupProfile (displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_, groupPreferences, memberAdmission) =
|
||||
GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_, groupPreferences, memberAdmission}
|
||||
toGroupProfile ((displayName, fullName, shortDescr, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (groupPreferences, memberAdmission)) =
|
||||
let publicGroupAccess = toPublicGroupAccess accessRow
|
||||
in GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ publicGroupAccess, groupPreferences, memberAdmission}
|
||||
|
||||
getGroupInfoByUserContactLinkConnReq :: DB.Connection -> VersionRangeChat -> User -> (ConnReqContact, ConnReqContact) -> IO (Maybe GroupInfo)
|
||||
getGroupInfoByUserContactLinkConnReq db vr user@User {userId} (cReqSchema1, cReqSchema2) = do
|
||||
|
||||
@@ -31,6 +31,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260403_item_viewed
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260429_relay_request_retries
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260507_relay_inactive_at
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260514_relay_request_group_link_index
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260515_public_group_access
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260529_delivery_job_senders
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260530_client_services
|
||||
import Simplex.Chat.Store.Postgres.Migrations.M20260531_member_removed_at
|
||||
@@ -65,6 +66,7 @@ schemaMigrations =
|
||||
("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries),
|
||||
("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at),
|
||||
("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index),
|
||||
("20260515_public_group_access", m20260515_public_group_access, Just down_m20260515_public_group_access),
|
||||
("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders),
|
||||
("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services),
|
||||
("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Store.Postgres.Migrations.M20260515_public_group_access where
|
||||
|
||||
import Data.Text (Text)
|
||||
import Text.RawString.QQ (r)
|
||||
|
||||
m20260515_public_group_access :: Text
|
||||
m20260515_public_group_access =
|
||||
[r|
|
||||
ALTER TABLE group_profiles ADD COLUMN group_web_page TEXT;
|
||||
ALTER TABLE group_profiles ADD COLUMN group_domain TEXT;
|
||||
ALTER TABLE group_profiles ADD COLUMN domain_web_page BIGINT;
|
||||
ALTER TABLE group_profiles ADD COLUMN allow_embedding BIGINT;
|
||||
|
||||
ALTER TABLE group_relays ADD COLUMN base_web_url TEXT;
|
||||
|]
|
||||
|
||||
down_m20260515_public_group_access :: Text
|
||||
down_m20260515_public_group_access =
|
||||
[r|
|
||||
ALTER TABLE group_relays DROP COLUMN base_web_url;
|
||||
|
||||
ALTER TABLE group_profiles DROP COLUMN allow_embedding;
|
||||
ALTER TABLE group_profiles DROP COLUMN domain_web_page;
|
||||
ALTER TABLE group_profiles DROP COLUMN group_domain;
|
||||
ALTER TABLE group_profiles DROP COLUMN group_web_page;
|
||||
|]
|
||||
@@ -850,7 +850,11 @@ CREATE TABLE test_chat_schema.group_profiles (
|
||||
short_descr text,
|
||||
group_type text,
|
||||
group_link bytea,
|
||||
public_group_id bytea
|
||||
public_group_id bytea,
|
||||
group_web_page text,
|
||||
group_domain text,
|
||||
domain_web_page bigint,
|
||||
allow_embedding bigint
|
||||
);
|
||||
|
||||
|
||||
@@ -875,7 +879,8 @@ CREATE TABLE test_chat_schema.group_relays (
|
||||
relay_link bytea,
|
||||
conf_id bytea,
|
||||
created_at text DEFAULT now() NOT NULL,
|
||||
updated_at text DEFAULT now() NOT NULL
|
||||
updated_at text DEFAULT now() NOT NULL,
|
||||
base_web_url text
|
||||
);
|
||||
|
||||
|
||||
|
||||
@@ -154,6 +154,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260429_relay_request_retries
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260507_relay_inactive_at
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260514_relay_request_group_link_index
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260515_public_group_access
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260529_delivery_job_senders
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260530_client_services
|
||||
import Simplex.Chat.Store.SQLite.Migrations.M20260531_member_removed_at
|
||||
@@ -311,6 +312,7 @@ schemaMigrations =
|
||||
("20260429_relay_request_retries", m20260429_relay_request_retries, Just down_m20260429_relay_request_retries),
|
||||
("20260507_relay_inactive_at", m20260507_relay_inactive_at, Just down_m20260507_relay_inactive_at),
|
||||
("20260514_relay_request_group_link_index", m20260514_relay_request_group_link_index, Just down_m20260514_relay_request_group_link_index),
|
||||
("20260515_public_group_access", m20260515_public_group_access, Just down_m20260515_public_group_access),
|
||||
("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders),
|
||||
("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services),
|
||||
("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{-# LANGUAGE QuasiQuotes #-}
|
||||
|
||||
module Simplex.Chat.Store.SQLite.Migrations.M20260515_public_group_access where
|
||||
|
||||
import Database.SQLite.Simple (Query)
|
||||
import Database.SQLite.Simple.QQ (sql)
|
||||
|
||||
m20260515_public_group_access :: Query
|
||||
m20260515_public_group_access =
|
||||
[sql|
|
||||
ALTER TABLE group_profiles ADD COLUMN group_web_page TEXT;
|
||||
ALTER TABLE group_profiles ADD COLUMN group_domain TEXT;
|
||||
ALTER TABLE group_profiles ADD COLUMN domain_web_page INTEGER;
|
||||
ALTER TABLE group_profiles ADD COLUMN allow_embedding INTEGER;
|
||||
|
||||
ALTER TABLE group_relays ADD COLUMN base_web_url TEXT;
|
||||
|]
|
||||
|
||||
down_m20260515_public_group_access :: Query
|
||||
down_m20260515_public_group_access =
|
||||
[sql|
|
||||
ALTER TABLE group_relays DROP COLUMN base_web_url;
|
||||
|
||||
ALTER TABLE group_profiles DROP COLUMN allow_embedding;
|
||||
ALTER TABLE group_profiles DROP COLUMN domain_web_page;
|
||||
ALTER TABLE group_profiles DROP COLUMN group_domain;
|
||||
ALTER TABLE group_profiles DROP COLUMN group_web_page;
|
||||
|]
|
||||
@@ -141,6 +141,7 @@ Query:
|
||||
SELECT
|
||||
-- GroupInfo
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id,
|
||||
gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding,
|
||||
g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission,
|
||||
g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at,
|
||||
g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id,
|
||||
@@ -970,6 +971,7 @@ SEARCH delivery_tasks USING COVERING INDEX idx_delivery_tasks_next (group_id=? A
|
||||
Query:
|
||||
SELECT gp.display_name, gp.full_name, gp.short_descr, gp.description, gp.image,
|
||||
gp.group_type, gp.group_link, gp.public_group_id,
|
||||
gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding,
|
||||
gp.preferences, gp.member_admission
|
||||
FROM group_profiles gp
|
||||
JOIN groups g ON gp.group_profile_id = g.group_profile_id
|
||||
@@ -1217,8 +1219,9 @@ Query:
|
||||
INSERT INTO group_profiles
|
||||
(display_name, full_name, short_descr, description, image,
|
||||
group_type, group_link, public_group_id,
|
||||
group_web_page, group_domain, domain_web_page, allow_embedding,
|
||||
user_id, preferences, member_admission, created_at, updated_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
|
||||
Plan:
|
||||
|
||||
@@ -1741,6 +1744,7 @@ Query:
|
||||
UPDATE group_profiles
|
||||
SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?,
|
||||
group_type = ?, group_link = ?,
|
||||
group_web_page = ?, group_domain = ?, domain_web_page = ?, allow_embedding = ?,
|
||||
preferences = ?, member_admission = ?, updated_at = ?
|
||||
WHERE group_profile_id IN (
|
||||
SELECT group_profile_id
|
||||
@@ -5107,6 +5111,14 @@ SEARCH group_profiles USING INTEGER PRIMARY KEY (rowid=?)
|
||||
LIST SUBQUERY 1
|
||||
SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
|
||||
|
||||
Query:
|
||||
UPDATE group_relays
|
||||
SET base_web_url = ?, updated_at = ?
|
||||
WHERE group_member_id = ?
|
||||
|
||||
Plan:
|
||||
SEARCH group_relays USING INDEX idx_group_relays_group_member_id (group_member_id=?)
|
||||
|
||||
Query:
|
||||
UPDATE group_relays
|
||||
SET conf_id = ?, relay_link = ?, updated_at = ?
|
||||
@@ -5291,6 +5303,7 @@ Query:
|
||||
SELECT
|
||||
-- GroupInfo
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id,
|
||||
gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding,
|
||||
g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission,
|
||||
g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at,
|
||||
g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id,
|
||||
@@ -5327,6 +5340,7 @@ Query:
|
||||
SELECT
|
||||
-- GroupInfo
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id,
|
||||
gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding,
|
||||
g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission,
|
||||
g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at,
|
||||
g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id,
|
||||
@@ -5356,6 +5370,7 @@ Query:
|
||||
SELECT
|
||||
-- GroupInfo
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id,
|
||||
gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding,
|
||||
g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission,
|
||||
g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at,
|
||||
g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id,
|
||||
@@ -5733,7 +5748,7 @@ SEARCH f USING INDEX idx_files_chat_item_id (chat_item_id=?)
|
||||
Query:
|
||||
SELECT gr.group_relay_id, gr.group_member_id,
|
||||
cr.chat_relay_id, cr.address, cr.display_name, cr.full_name, cr.short_descr, cr.image, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted,
|
||||
gr.relay_status, gr.relay_link
|
||||
gr.relay_status, gr.relay_link, gr.base_web_url
|
||||
FROM group_relays gr
|
||||
JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id
|
||||
|
||||
@@ -5750,7 +5765,7 @@ SEARCH m USING INTEGER PRIMARY KEY (rowid=?)
|
||||
Query:
|
||||
SELECT gr.group_relay_id, gr.group_member_id,
|
||||
cr.chat_relay_id, cr.address, cr.display_name, cr.full_name, cr.short_descr, cr.image, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted,
|
||||
gr.relay_status, gr.relay_link
|
||||
gr.relay_status, gr.relay_link, gr.base_web_url
|
||||
FROM group_relays gr
|
||||
JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id
|
||||
WHERE gr.group_id = ?
|
||||
@@ -5761,7 +5776,7 @@ SEARCH cr USING INTEGER PRIMARY KEY (rowid=?)
|
||||
Query:
|
||||
SELECT gr.group_relay_id, gr.group_member_id,
|
||||
cr.chat_relay_id, cr.address, cr.display_name, cr.full_name, cr.short_descr, cr.image, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted,
|
||||
gr.relay_status, gr.relay_link
|
||||
gr.relay_status, gr.relay_link, gr.base_web_url
|
||||
FROM group_relays gr
|
||||
JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id
|
||||
WHERE gr.group_member_id = ?
|
||||
@@ -5772,7 +5787,7 @@ SEARCH cr USING INTEGER PRIMARY KEY (rowid=?)
|
||||
Query:
|
||||
SELECT gr.group_relay_id, gr.group_member_id,
|
||||
cr.chat_relay_id, cr.address, cr.display_name, cr.full_name, cr.short_descr, cr.image, cr.domains, cr.preset, cr.tested, cr.enabled, cr.deleted,
|
||||
gr.relay_status, gr.relay_link
|
||||
gr.relay_status, gr.relay_link, gr.base_web_url
|
||||
FROM group_relays gr
|
||||
JOIN chat_relays cr ON cr.chat_relay_id = gr.chat_relay_id
|
||||
WHERE gr.group_relay_id = ?
|
||||
|
||||
@@ -126,7 +126,11 @@ CREATE TABLE group_profiles(
|
||||
short_descr TEXT,
|
||||
group_type TEXT,
|
||||
group_link BLOB,
|
||||
public_group_id BLOB
|
||||
public_group_id BLOB,
|
||||
group_web_page TEXT,
|
||||
group_domain TEXT,
|
||||
domain_web_page INTEGER,
|
||||
allow_embedding INTEGER
|
||||
) STRICT;
|
||||
CREATE TABLE groups(
|
||||
group_id INTEGER PRIMARY KEY, -- local group ID
|
||||
@@ -781,6 +785,8 @@ CREATE TABLE group_relays(
|
||||
conf_id BLOB,
|
||||
created_at TEXT NOT NULL DEFAULT(datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT(datetime('now'))
|
||||
,
|
||||
base_web_url TEXT
|
||||
) STRICT;
|
||||
CREATE INDEX contact_profiles_index ON contact_profiles(
|
||||
display_name,
|
||||
|
||||
@@ -665,18 +665,20 @@ type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe Member
|
||||
|
||||
type GroupKeysRow = (Maybe C.PrivateKeyEd25519, Maybe C.PublicKeyEd25519, Maybe C.PrivateKeyEd25519)
|
||||
|
||||
type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe Int64, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupKeysRow :. GroupMemberRow
|
||||
type GroupInfoRow = (Int64, GroupName, GroupName, Text, Maybe Text, Text, Maybe Text, Maybe ImageData, Maybe GroupType, Maybe ShortLinkContact, Maybe B64UrlByteString) :. PublicGroupAccessRow :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. PreparedGroupRow :. BusinessChatInfoRow :. (BoolInt, Maybe RelayStatus, Maybe UIThemeEntityOverrides, Int64, Maybe Int64, Maybe CustomData, Maybe Int64, Int, Maybe ConnReqContact) :. GroupKeysRow :. GroupMemberRow
|
||||
|
||||
type PublicGroupAccessRow = (Maybe Text, Maybe Text, Maybe BoolInt, Maybe BoolInt)
|
||||
|
||||
type GroupMemberRow = (GroupMemberId, GroupId, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId) :. ProfileRow :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact)
|
||||
|
||||
type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences)
|
||||
|
||||
toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo
|
||||
toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) =
|
||||
toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, shortDescr, localAlias, description, image, groupType_, groupLink_, publicGroupId_) :. accessRow :. (enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. preparedGroupRow :. businessRow :. (BI useRelays, relayOwnStatus, uiThemes, currentMembers, publicMemberCount, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri) :. groupKeysRow :. userMemberRow) =
|
||||
let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr}
|
||||
chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite}
|
||||
fullGroupPreferences = mergeGroupPreferences groupPreferences
|
||||
publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_
|
||||
publicGroup = toPublicGroupProfile groupType_ groupLink_ publicGroupId_ (toPublicGroupAccess accessRow)
|
||||
groupKeys = toGroupKeys publicGroupId_ groupKeysRow
|
||||
groupProfile = GroupProfile {displayName, fullName, shortDescr, description, image, publicGroup, groupPreferences, memberAdmission}
|
||||
businessChat = toBusinessChatInfo businessRow
|
||||
@@ -690,10 +692,25 @@ toPreparedGroup = \case
|
||||
Just PreparedGroup {connLinkToConnect = CCLink fullLink shortLink_, connLinkPreparedConnection, connLinkStartedConnection, welcomeSharedMsgId, requestSharedMsgId}
|
||||
_ -> Nothing
|
||||
|
||||
toPublicGroupProfile :: Maybe GroupType -> Maybe ShortLinkContact -> Maybe B64UrlByteString -> Maybe PublicGroupProfile
|
||||
toPublicGroupProfile (Just groupType) (Just groupLink) (Just publicGroupId) =
|
||||
Just PublicGroupProfile {groupType, groupLink, publicGroupId}
|
||||
toPublicGroupProfile _ _ _ = Nothing
|
||||
toPublicGroupProfile :: Maybe GroupType -> Maybe ShortLinkContact -> Maybe B64UrlByteString -> Maybe PublicGroupAccess -> Maybe PublicGroupProfile
|
||||
toPublicGroupProfile (Just groupType) (Just groupLink) (Just publicGroupId) publicGroupAccess =
|
||||
Just PublicGroupProfile {groupType, groupLink, publicGroupId, publicGroupAccess}
|
||||
toPublicGroupProfile _ _ _ _ = Nothing
|
||||
|
||||
publicGroupAccessRow :: Maybe PublicGroupProfile -> PublicGroupAccessRow
|
||||
publicGroupAccessRow pgp = case pgp >>= publicGroupAccess of
|
||||
Just PublicGroupAccess {groupWebPage, groupDomain, domainWebPage, allowEmbedding} ->
|
||||
(groupWebPage, groupDomain, Just (BI domainWebPage), Just (BI allowEmbedding))
|
||||
Nothing -> (Nothing, Nothing, Nothing, Nothing)
|
||||
|
||||
toPublicGroupAccess :: PublicGroupAccessRow -> Maybe PublicGroupAccess
|
||||
toPublicGroupAccess (groupWebPage, groupDomain, domainWebPage_, allowEmbedding_)
|
||||
| isJust groupWebPage || isJust groupDomain || domainWebPage || allowEmbedding =
|
||||
Just PublicGroupAccess {groupWebPage, groupDomain, domainWebPage, allowEmbedding}
|
||||
| otherwise = Nothing
|
||||
where
|
||||
domainWebPage = maybe False unBI domainWebPage_
|
||||
allowEmbedding = maybe False unBI allowEmbedding_
|
||||
|
||||
toGroupKeys :: Maybe B64UrlByteString -> GroupKeysRow -> Maybe GroupKeys
|
||||
toGroupKeys (Just publicGroupId) (rootPrivKey_, rootPubKey_, Just memberPrivKey) =
|
||||
@@ -760,6 +777,7 @@ groupInfoQueryFields =
|
||||
SELECT
|
||||
-- GroupInfo
|
||||
g.group_id, g.local_display_name, gp.display_name, gp.full_name, gp.short_descr, g.local_alias, gp.description, gp.image, gp.group_type, gp.group_link, gp.public_group_id,
|
||||
gp.group_web_page, gp.group_domain, gp.domain_web_page, gp.allow_embedding,
|
||||
g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission,
|
||||
g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at,
|
||||
g.conn_full_link_to_connect, g.conn_short_link_to_connect, g.conn_link_prepared_connection, g.conn_link_started_connection, g.welcome_shared_msg_id, g.request_shared_msg_id,
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
module Simplex.Chat.Terminal where
|
||||
|
||||
import Control.Monad
|
||||
import Control.Monad.IO.Class (liftIO)
|
||||
import qualified Data.List.NonEmpty as L
|
||||
import Simplex.Chat (defaultChatConfig)
|
||||
import Simplex.Chat.Controller
|
||||
@@ -23,8 +22,6 @@ import Simplex.Chat.Terminal.Output
|
||||
import Simplex.FileTransfer.Client.Presets (defaultXFTPServers)
|
||||
import Simplex.Messaging.Client (NetworkConfig (..), SMPProxyFallback (..), SMPProxyMode (..), defaultNetworkConfig)
|
||||
import Simplex.Messaging.Util (raceAny_)
|
||||
import System.Terminal (Key, Modifiers)
|
||||
import UnliftIO.STM
|
||||
#if !defined(dbPostgres)
|
||||
import Control.Exception (handle, throwIO)
|
||||
import qualified Data.ByteArray as BA
|
||||
@@ -102,9 +99,4 @@ simplexChatTerminal cfg options t = run options
|
||||
#endif
|
||||
|
||||
runChatTerminal :: ChatTerminal -> ChatController -> ChatOpts -> IO ()
|
||||
runChatTerminal ct cc opts = do
|
||||
keyQ <- newTQueueIO
|
||||
raceAny_ [runKeyReader ct keyQ, runTerminalInput ct cc keyQ, runTerminalOutput ct cc opts, runInputLoop ct cc]
|
||||
|
||||
runKeyReader :: ChatTerminal -> TQueue (Key, Modifiers) -> IO ()
|
||||
runKeyReader ct q = withChatTerm ct $ forever $ getKey >>= liftIO . atomically . writeTQueue q
|
||||
runChatTerminal ct cc opts = raceAny_ [runTerminalInput ct cc, runTerminalOutput ct cc opts, runInputLoop ct cc]
|
||||
|
||||
@@ -152,14 +152,14 @@ sendUpdatedLiveMessage cc sentMsg LiveMessage {chatName, chatItemId} live = do
|
||||
let cmd = UpdateLiveMessage chatName chatItemId live $ T.pack sentMsg
|
||||
execChatCommand' cmd 0 `runReaderT` cc
|
||||
|
||||
runTerminalInput :: ChatTerminal -> ChatController -> TQueue (Key, Modifiers) -> IO ()
|
||||
runTerminalInput ct cc keyQ = do
|
||||
updateInputView ct
|
||||
receiveFromTTY keyQ cc ct
|
||||
runTerminalInput :: ChatTerminal -> ChatController -> IO ()
|
||||
runTerminalInput ct cc = withChatTerm ct $ do
|
||||
updateInput ct
|
||||
receiveFromTTY cc ct
|
||||
|
||||
receiveFromTTY :: TQueue (Key, Modifiers) -> ChatController -> ChatTerminal -> IO ()
|
||||
receiveFromTTY keyQ cc@ChatController {inputQ, currentUser, currentRemoteHost, chatStore} ct@ChatTerminal {termSize, termState, liveMessageState, activeTo} =
|
||||
forever $ atomically (readTQueue keyQ) >>= processKey >> updateInputView ct
|
||||
receiveFromTTY :: forall m. MonadTerminal m => ChatController -> ChatTerminal -> m ()
|
||||
receiveFromTTY cc@ChatController {inputQ, currentUser, currentRemoteHost, chatStore} ct@ChatTerminal {termSize, termState, liveMessageState, activeTo} =
|
||||
forever $ getKey >>= liftIO . processKey >> withTermLock ct (updateInput ct)
|
||||
where
|
||||
processKey :: (Key, Modifiers) -> IO ()
|
||||
processKey key = case key of
|
||||
|
||||
@@ -795,10 +795,19 @@ instance FromField GroupType where fromField = fromTextField_ textDecode
|
||||
|
||||
instance ToField GroupType where toField = toField . textEncode
|
||||
|
||||
data PublicGroupAccess = PublicGroupAccess
|
||||
{ groupWebPage :: Maybe Text,
|
||||
groupDomain :: Maybe Text,
|
||||
domainWebPage :: Bool,
|
||||
allowEmbedding :: Bool
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
data PublicGroupProfile = PublicGroupProfile
|
||||
{ groupType :: GroupType,
|
||||
groupLink :: ShortLinkContact,
|
||||
publicGroupId :: B64UrlByteString -- group identity = sha256(genesis root key), immutable
|
||||
publicGroupId :: B64UrlByteString, -- group identity = sha256(genesis root key), immutable
|
||||
publicGroupAccess :: Maybe PublicGroupAccess
|
||||
}
|
||||
deriving (Eq, Show)
|
||||
|
||||
@@ -2086,6 +2095,8 @@ instance ToJSON GroupType where
|
||||
toJSON = textToJSON
|
||||
toEncoding = textToEncoding
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''PublicGroupAccess)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''PublicGroupProfile)
|
||||
|
||||
$(JQ.deriveJSON defaultJSON ''GroupProfile)
|
||||
|
||||
@@ -3299,6 +3299,12 @@ testGLinkReviewMember =
|
||||
alice ##> "/_delete member chat #1 5"
|
||||
alice <## "bad chat command: member is pending"
|
||||
|
||||
-- moderator can't accept member with a role higher than their own
|
||||
dan ##> "/_accept member #1 5 admin"
|
||||
dan <## "#team: you have insufficient permissions for this action, the required role is admin"
|
||||
dan ##> "/_accept member #1 5 owner"
|
||||
dan <## "#team: you have insufficient permissions for this action, the required role is owner"
|
||||
|
||||
-- accept member
|
||||
dan ##> "/_accept member #1 5 member"
|
||||
concurrentlyN_
|
||||
|
||||
Reference in New Issue
Block a user