core: add optional profile description

This commit is contained in:
Evgeny @ SimpleX Chat
2026-07-14 13:50:41 +00:00
parent 6dce9e3903
commit 9b6b8089c2
27 changed files with 745 additions and 98 deletions
@@ -638,8 +638,8 @@ directoryServiceEvent st opts@DirectoryOpts {adminUsers, superUsers, serviceName
Right CRGroupLink {groupLink = GroupLink {connLinkContact = CCLink cr sl_}} ->
let linkBefore_ = profileGroupLinkText fromGroup
linkNow_ = profileGroupLinkText toGroup
profileGroupLinkText GroupInfo {groupProfile = gp} =
maybe Nothing (fmap (\(FormattedText _ t) -> t) . find ftHasLink) $ parseMaybeMarkdownList =<< description gp
profileGroupLinkText GroupInfo {groupProfile = GroupProfile {description = descr_}} =
maybe Nothing (fmap (\(FormattedText _ t) -> t) . find ftHasLink) $ parseMaybeMarkdownList =<< descr_
ftHasLink = \case
FormattedText (Just SimplexLink {simplexUri = ACL SCMContact cLink}) _ -> case cLink of
CLFull cr' -> sameConnReqContact cr' cr
@@ -0,0 +1,509 @@
# Directory registration of businesses and service bots (via signed contact card)
Status: draft plan for review. Grounded against the current tree (branch `ep/improve-names-2`).
## 1. Goal
Let a business or a service (chat bot) operator register their **contact `/a` address** in
the directory by **forwarding a signed contact card** to the directory bot — exactly the
UX we already have for channels (`/share chat #ch @'SimpleX Directory'`), but for a contact
address instead of a channel link.
**Guiding principle: the flow is the channel registration flow verbatim — owner-signed card, admin
approval, re-approval on any profile change, the same periodic link-check loop — differing only in
the listing type (a contact `peerType`, not a group). Where a detail is unspecified here, the answer
is "whatever channels do."**
Product decisions from the discussion, baked into this plan:
- **The owner sends the card, and the signature is the authorization.** The `ownerSig` (signed
with the address key) is what proves the address owner authorized the listing — only the key
holder can produce it. We deliberately do NOT use a "directory connects and asks the owner to
confirm" double opt-in (it is a spam vector, like any mailing-list signup). "Submitter ≠ owner"
is handled not by letting non-owners submit, but by giving the owner's tooling a way to send (the
support-bot entry point that would cover the headless case is deferred — §B.4).
- **The directory does NOT connect to / join these addresses.** They are not groups. It verifies
the signature via a link-data *fetch* (not a connection) and records the address in a new table.
- **One table for both businesses and bots** — they are all contact `/a` addresses. The MVP types
each accepted registration by `peerType`: a **bot** requires `peerType == CPTBot`; a **business**
requires `peerType ∈ {CPTHuman, CPTBusiness}` (an unset `peerType` counts as `CPTHuman`); an
unrecognized `CPTUnknown` is rejected. The admin then manually verifies a business before
approving — as for channels.
- **Listing type = `ChatPeerType`.** Extend `ChatPeerType` (today `CPTHuman | CPTBot`, `Types.hs:710`)
with **`CPTBusiness`** and **`CPTUnknown Text`** (forward-compat, like `GTUnknown`), and make the
decoder **lenient** (unknown tag → `CPTUnknown`) so this version won't choke on future tags.
**Wire-compat caveat (verified):** `ChatPeerType` decodes strictly today
(`textDecode … _ -> Nothing`, `Profile` via `deriveJSON`), so a present-but-unknown `peerType`
makes an *already-deployed* app fail to parse the whole profile — it does **not** downgrade to
human. So a business must **not** publish `CPTBusiness` yet (old apps couldn't reach it); a
business's profile stays `CPTHuman` in practice, with `CPTBusiness` reserved for later. The MVP
types a **bot** from `peerType == CPTBot` and a **business** from `peerType ∈ {CPTHuman,
CPTBusiness}` (unset ≙ `CPTHuman`), rejecting `CPTUnknown`; it stores the resolved type
(`CPTBot`/`CPTBusiness`) on the listing. When the lenient version is broadly adopted, businesses
can publish `CPTBusiness` directly.
- **`peerType` and `businessAddress` are orthogonal, and the directory ignores `businessAddress`.**
`businessAddress` chooses the *conversation type* a connector gets (a business chat / group vs a
direct 1:1); it can be set by non-businesses, and a real business may run a plain direct-chat
address. The directory does **not** use it to classify — the type comes from the profile's
`peerType` (bot vs human/business, above), not from `businessAddress`. (App-side only, unchanged:
the connect-preview briefcase shows when **either** `businessAddress` or `peerType == CPTBusiness`;
bot cube from `peerType`, else person — in the MVP that briefcase comes from `businessAddress`.
Separate from directory classification.)
- **Description lives on the contact `Profile`** (new `description` field, parallel to
`GroupProfile.description`). In group-member profiles it is **redacted per the group's policy —
the same treatment `shortDescr` gets** (links/names stripped when the group prohibits them), not
removed wholesale. It is carried **full** in the direct contact view, the address link preview,
and the directory. See §G.
- **`peerType` + `description` are visible in the app independent of the directory** (that is why
owners will set them). `peerType` drives the type icon in the pre-connect alert
(`ConnectPlan.kt:698-713`) and a marker in the chat list / chat banner. The compact surfaces (the
alert, the shared-link card) are too small for the large `description`, so it appears via a
**"Read more"** affordance in the **chat banner** (`ChatView.kt` `ChatBannerView`) and the
**contact info page** (`ChatInfoView.kt:778`) that opens the full text in a sheet (iOS) / alert
(Kotlin). These are NOT the welcome/auto-reply message (`AddressSettings.autoReply`, transient
on-connect). Full details in §H.
Deliverables: (a) an API + CLI to prepare and share the signed contact card; (b) the
`Profile.description` field; (c) directory handling that verifies and stores the address; (d) admin
approval, web listing, and search. (A support-bot entry point for headless businesses is out of
scope for now — §B.4.)
## 2. End-to-end flow
```
Operator's client Directory bot
----------------- -------------
/share address @'SimpleX Directory'
-> get own /a address (short link,
businessAddress flag, root key)
-> build MCChat { chatLink =
MCLContact {connLink, profile, business},
ownerSig = sign(rootPrivKey,
chatBinding <> connLink) } ── card ──▶ DEChatLinkReceived (MCLContact, ownerSig)
-> APIConnectPlan (PLAN only, no connect)
fetches link data (opaque) + verifies sig
=> CPContactAddress (CAPOk {ownerVerification})
-> if OVVerified:
addContactReg (bot if CPTBot,
else business), status pending
notify admins with profile (admin verifies)
admins: /approve ... -> status active -> listingsUpdated
-> web listing.json + bot search include it
```
Nothing is connected or joined. The only network action on the directory side is a one-time,
opaque link-data fetch for signature verification (consistent with the established rule that
the directory may fetch link data, only name *resolution* leaks membership).
## 3. What already exists (reuse map)
All grounded in the current tree:
- **Chat-link card type** — `MCLContact {connLink :: ShortLinkContact, profile :: Profile, business :: Bool}`
already exists (`src/Simplex/Chat/Protocol.hs:769`). `MCChat {text, chatLink, ownerSig}` and
`LinkOwnerSig {ownerId, chatBinding, ownerSig}` at `Protocol.hs:764,774`.
- **Owner-signature verification for contact addresses is already wired.** `connectPlan`'s
`CTShortContact CCTContact` path fetches `FixedLinkData {rootKey}` + `UserContactData {owners}`
and computes `ov = verifyLinkOwner rootKey owners l' sig_`, surfaced as
`CPContactAddress (CAPOk {contactSLinkData_, ownerVerification})`
(`src/Simplex/Chat/Library/Commands.hs:4287-4289,4518-4527`; `Controller.hs:1114-1121,1139-1142`).
For plain/business addresses `owners == []`, so `ownerId = Nothing` and verification uses the
link **root key** (`verifyLinkOwner` fallback).
- **The directory already receives any `MCChat` card as `DEChatLinkReceived`** — `Directory/Events.hs:108`
turns `(MCChat {chatLink, ownerSig}, Nothing)` into `DEChatLinkReceived`. Today `deChatLinkReceived`
only matches `MCLGroup` and otherwise replies "Only channels can be added to directory via link."
(`Directory/Service.hs:964-979`). We add an `MCLContact` case.
- **Card-sharing UI + API + signing** — `/share chat #g @to``SharePublicGroup`
(`Commands.hs:2437-2449`, parser `Commands.hs:5551`) → `APIShareChatMsgContent`
(`Commands.hs:1136-1170`) which builds the `MCChat` and signs with `mkLinkOwnerSig` +
`shareChatBinding` (binds the card to the recipient connection, anti-replay).
- **Address key + business flag storage** — `link_priv_sig_key` (the address root private key,
Ed25519) is stored in `user_contact_links` by `createUserContactLink`
(`src/Simplex/Chat/Store/Profiles.hs:429-439`); `businessAddress` lives in `AddressSettings`
(`Profiles.hs:497-502`) and is published as `ContactShortLinkData.business`
(`Commands.hs:4528-4533`, `Protocol.hs:1584-1592`). Note: `getUserAddress`/`UserContactLink`
do **not** currently read `link_priv_sig_key` back (`Profiles.hs:479-524`).
- **Directory store / listing / web infra** — `sx_directory_group_regs` table
(`Directory/Store/{SQLite,Postgres}/Migrations.hs`), `GroupReg`/`GroupRegStatus`
(`Directory/Store.hs:116-226`), `getAllListedGroups_` (`Store.hs:354-363`), `generateListing`
(`Directory/Listing.hs:148-170`), `DirectoryEntry`/`DirectoryEntryType = DETGroup`
(`Listing.hs:55-86`), website renderer `website/src/js/directory.jsc`.
## 4. Work items
### A. Protocol / types
- `MCLContact` exists; no new protocol message for the card itself.
- **Extend `ChatPeerType`** (`Types.hs:710`, today `CPTHuman | CPTBot`) with `CPTBusiness` and
`CPTUnknown Text` (forward-compat, like `GTUnknown`). Update the `TextEncoding`/JSON instances
(`Types.hs:724-731`): encode `CPTBusiness` as `"business"` and `CPTUnknown t` back to `t`
(round-trips the original tag); make `textDecode` **lenient** — an unrecognized tag becomes
`CPTUnknown t` instead of `Nothing`, so this version never fails to parse a profile with a future
tag. **Verified constraint:** the *current* decoder is strict (`_ -> Nothing`) and `Profile`
is `deriveJSON`-parsed, so an already-deployed app fails the whole profile on an unknown `peerType`;
therefore `CPTBusiness` must not be published on profiles until the lenient version is broadly
adopted. **MVP:** the directory types a **bot** from `peerType == CPTBot` and a **business** from
`peerType ∈ {CPTHuman, CPTBusiness}` (unset ≙ `CPTHuman`; stored as `CPTBusiness`), and **rejects
`CPTUnknown`**. Businesses are then admin-verified — the admin is the gate, as for channels.
- **New optional `description :: Maybe Text` on `Profile`** (`Types.hs:693`), parallel to
`GroupProfile.description` (`Types.hs:867`). Additive/nullable — only businesses/bots set it.
It rides into the address link data automatically (`ContactShortLinkData` embeds the whole
`Profile`, `Protocol.hs:1584`), so the directory reads it from the fetched link data. It is
redacted per group policy in group-member profiles, on **both send and receive** (see §G). No
version bump is needed — `Profile` is `deriveJSON`-parsed and aeson ignores unknown keys, so old
apps just drop `description` (same as when `peerType`/`badge`/`contactDomain` were added).
- **Setting `peerType`/`description` (for tests + eventual UI).** Both are plain `Profile` fields, so
they ride through the existing profile-update path (`APIUpdateProfile` / the `/p` command); tests
drive them via `/_profile`. A small dedicated setter for the multi-line `description` is worth
adding for CLI ergonomics. The app-UI toggle to set `peerType = CPTBusiness` is deferred (per the
wire-compat caveat above).
### B. Client: prepare + share the contact-address card
1. **Signing key — from the agent, not the chat DB.** Sign the card with the address short-link key
via `getConnLinkPrivKey (aConnId addressConn)` (already in the agent, used at `Subscriber.hs:1649`;
`getUserAddressConnection` gives the connection). This is the authoritative key — the private half
of the short link's root key the directory verifies against — and it exists whenever the short link
does, **including right after an upgrade** (`setConnShortLink` provisions it). Do **not** read the
chat-DB `link_priv_sig_key` for signing: it is written only at `createUserContactLink` and never on
upgrade. *(Separate cleanup, off the signing path: persist `link_priv_sig_key` on upgrade too —
`setMyAddressData`/`setUserContactLinkShortLink` — reading it back via `getConnLinkPrivKey` so the
column stops being stale.)*
2. **Card-builder API — `APIShareMyAddress {toSendRef :: SendRef}`** (Controller) + handler in
`Commands.hs`, mirroring the group-share case (`APIShareChatMsgContent`, `Commands.hs:1136`):
- `getUserAddress``connLinkContact` (short link) + profile + `businessAddress`.
- `getUserAddressConnection` → conn; `getConnLinkPrivKey (aConnId conn)``rootPrivKey`
(`Nothing` ⇒ not upgraded → error; the UI pre-empts this via §B.5).
- hoist `shareChatBinding` to top-level; `binding <- shareChatBinding user toSendRef`.
- `ownerSig = LinkOwnerSig {ownerId = Nothing, chatBinding = B64UrlByteString cb,
ownerSig = C.sign' rootPrivKey (cb <> smpEncode connShortLink)}` (contact variant of
`mkLinkOwnerSig`, `ownerId = Nothing` so the directory verifies against the link root key).
- return `CRChatMsgContent user (MCChat {text, chatLink = MCLContact {connLink, profile, business}, ownerSig})`.
`SendRef` covers direct **and** group/channel targets.
3. **CLI command — `ShareMyAddress {toChatName}`**, parser `/share address @to` / `/share address #to`
(`Commands.hs:5551` neighborhood), handler mirroring `SharePublicGroup` (`Commands.hs:2437-2449`):
resolve `toChatName` → `SendRef` → `APIShareMyAddress` → `APISendMessages`. Shares to contacts and
groups/channels alike.
4. **Support-bot entry point — OUT OF SCOPE (deferred).** A headless business running
`apps/simplex-support-bot` (TypeScript, no app UI) will eventually need a way to trigger the
share — a bot admin/config command that calls `APIShareMyAddress` against the directory contact
once connected. Deferred; the core `APIShareMyAddress`/`/share address` path built here is exactly
what it will call.
5. **App UI — "Share via chat" (Phase 1; mirrors the channel share).** The receiving/rendering half
already exists from the channel work (`MsgChatLink.Contact`, `CIChatLinkHeader`, the compose
preview, and the `SharedContent → ShareListView → ComposeView` picker). New pieces: the entry
point, a `SharedContent.AddressLink` case, the `apiShareMyAddress` call, and the upgrade branch.
- **Entry point:** a **"Share via chat"** button (reuse the channel string) in the user's own
address screen (`UserAddressView.kt`), beside the existing OS-share "Share" button. Address
creation lands on this same screen (`createAddress` sets `userAddress`, `UserAddressView.kt:73-84`
— verified), so the button is visible immediately after creating an address.
- **Flow:** tap → if `userAddress.shouldBeUpgraded` (old full address) show an **upgrade alert**
("To share your address in a chat it will be upgraded to a short link. All your contacts stay
connected."), buttons **[Upgrade & share]** / **[Cancel]** — on confirm: spinner →
`apiAddMyAddressShortLink`, **then** continue (two separate API calls, cleaner errors); no
"share old" option. Then set `SharedContent.AddressLink` → `ShareListView` (contacts +
groups/channels, with the simplex-link prohibition filtering) → pick destination → `ComposeView`
`LaunchedEffect` calls `apiShareMyAddress` → sets the existing `ChatLinkPreview` → optional
message text (same UX as the channel share) → **Send** → the recipient sees the existing
`CIChatLinkHeader` card and taps to connect.
- iOS mirrors this via the existing channel-share flow (`f49d98511`); Kotlin per
`plans/2026-04-17-kotlin-share-channel-link.md`.
### C. Directory: verify + store (no connect)
1. **`deChatLinkReceived` — add the `MCLContact` case** (`Directory/Service.hs:964`).
**No new verification code** — reuse the exact plan path channels use (resolved, Q1):
- `deChatLinkReceived ct (MCLContact {connLink, profile, business}) (Just ownerSig)`:
- `APIConnectPlan userId (contact link) PRMAll (Just ownerSig)` — **plan only**, no connect
(rename `PRMAllGroups` → `PRMAll` and make it work for contact links too, not just groups).
`connectPlan`'s contact-address path already computes `ov = verifyLinkOwner rootKey owners l' sig_`
(`Commands.hs:4288`), identical to the channel path at `Commands.hs:4346`; for a plain/business
address `owners == []`, so the card's `ownerId = Nothing` makes `verifyLinkOwner` verify against
the link **root key**. Expect `CPContactAddress (CAPOk {contactSLinkData_ = Just csld, ownerVerification})`.
Use the **fetched** `csld.profile` (`peerType`, `description`, name claim, …) as authoritative
— the card's copies are display-only / potentially stale; `csld.business` is not used for
typing.
- `OVVerified` → type from the fetched profile's `peerType`: **bot** if `CPTBot`, **business**
if `CPTHuman`/`CPTBusiness` (unset ≙ `CPTHuman`; stored as `CPTBusiness`) → `addContactReg`
status pending; **reject `CPTUnknown`** ("unsupported account type"). A business is then
admin-verified before approving (as for channels). `OVFailed reason` → "ownership verification
failed". `CAPKnown`/other → appropriate message.
- The fetch is intrinsic (the root public key isn't in the card, same as channels) and is an
opaque link-data read, not a connection — consistent with the established directory rule.
- Keep the existing `MCLGroup` and fall-through cases unchanged.
2. **New store: `sx_directory_contact_regs`.** Add a migration to
`Directory/Store/SQLite/Migrations.hs` and `Directory/Store/Postgres/Migrations.hs` (new named
migration appended to `schemaMigrations`). Proposed columns:
```
contact_reg_id PK autoincrement
user_contact_reg_id INTEGER -- per-submitter sequence (cf. user_group_reg_id)
submitter_contact_id INTEGER NOT NULL REFERENCES contacts ON DELETE CASCADE
conn_short_link TEXT NOT NULL -- the contact link; the LISTING IDENTITY (stable for
-- contacts). A link change ⇒ unlist + re-register.
-- Must be present in the fetched profile (contactLink).
display_name TEXT NOT NULL
full_name TEXT
short_descr TEXT
description TEXT -- long description (Profile.description)
image TEXT -- base64, optional
peer_type TEXT NOT NULL -- resolved listing type: "bot" or "business" (ChatPeerType)
simplex_name TEXT -- verified SimpleX name, optional (see Q5)
reg_status TEXT NOT NULL
promoted INTEGER NOT NULL DEFAULT 0
created_at, updated_at TEXT
UNIQUE(conn_short_link); UNIQUE(submitter_contact_id, user_contact_reg_id)
```
Records store the profile inline (`display_name`, `description`, `image`, `peerType`, …) —
self-contained, no FK to a joined contact, since we never connect. Reuse **`GroupRegStatus`**
(resolved, Q3) for `reg_status` — same states as channels. New `Directory/Store.hs` data +
functions mirroring the `GroupReg` ones: `ContactReg`, `addContactReg`, `setContactRegStatus`,
`deleteContactReg`, `getContactRegBy{Id,Link}`, `getAllListedContacts`, and a search query.
3. **Registration lifecycle mirrors channels.** `proposed → pending approval → active`, plus
`suspended/removed`. On submission, notify admins with the profile + an approve command. The
directory **re-reads the address links in the same periodic loop as channels** (resolved, Q6 —
`deGroupLinkCheck`, `Service.hs:832`): re-fetch the link data, refresh the stored profile, and
a profile change triggers **re-approval** (hidden until re-approved), exactly like a channel
profile change (`reapprove`, `Service.hs:858`). The loop is the same as channels; a contact
address has a single key and no group membership, so the channel `checkValidOwner` owner-list
re-check has no analog and doesn't run — which also means the empty-`linkOwners` false-delist bug
can't arise, and `UNIQUE(conn_short_link)` (below) prevents the duplicate-row class that triggered
it. **Re-submission of an already-registered link (to research + propose):** mirror the channel
re-registration path (`deReregistration`) — upsert the existing reg and send it back to admin
review on any change, rather than erroring.
4. **Admin & user commands — same commands, extended with a chat type** (resolved, Q4). Reuse the
existing command constructors and syntax; carry a chat-type discriminator on the id token — `#`
for a group (existing), `@` for a contact address — e.g. `/approve @<id>:<name> <version>`,
`/list @...`, `/suspend @...`, mirroring the group forms. The `@`/`#` prefix disambiguates the
overlapping id spaces (a `group_id` and a `contact_reg_id` both start at 1), so no parallel
command names are needed. Extend the command constructors with the chat type, extend
`Directory/Events.hs` `directoryCmdP` to parse the prefix, and branch on it in `Service.hs`
`deSuperUserCommand`/`deUserCommand`.
5. **Link identity + verified SimpleX names (resolved).** The contact **link is the listing
identity** (for contacts the link is expected to be stable). Two conditions:
- **Link present in the profile.** For listing, the fetched profile must declare this link —
`Profile.contactLink` present and equal to the registered link. If the link **changes** (or the
profile stops declaring it), the address is **unlisted** and must be re-registered — the link is
the anchor, not a mutable attribute.
- **Name↔link consistency, verified inline.** If the profile claims a SimpleX name
(`Profile.contactDomain`), resolve that name and confirm it points to **this** link, comparing
inline — the reverse direction of the existing by-name plan path (`Commands.hs:4272-4281`,
`contactDomain`/`nameResolvesTo`). A **name change** re-runs this check. On success, populate
`sx_directory_contact_regs.simplex_name` → flows to `DirectoryEntry.simplexName` and bot/web
search. Reuse `plans/2026-06-25-name-resolution.md` and the group-names work.
*(To research + propose: how the directory detects a link change on re-read, whether an address's
published profile actually carries `contactLink == its own link`, and the exact resolve-and-compare
call for the link → name-claim → link round-trip.)*
### D. Listing + web
1. **`DirectoryEntryType`** (`Listing.hs:55`): add `DETContact {peerType :: ChatPeerType}`. The
`taggedObjectJSON`/`dropPrefix "DET"` derivation already emits `{"type":"contact", ...}` for a new
constructor for free (single→multi constructor is transparent); `peerType` serializes as
`"business"`/`"bot"`/etc.
2. **`contactDirectoryEntry`** builder (analogue of `groupDirectoryEntry`, `Listing.hs:100`), from a
`ContactReg` row: `DirectoryEntry {entryType = DETContact peerType, displayName, simplexName,
groupLink = PublicLink Nothing (Just connShortLink), shortDescr` (from `Profile.shortDescr`)`,
welcomeMessage` (from the new `Profile.description`)`, imageFile, activeAt, createdAt}`.
`PublicLink` already models contact links (`Listing.hs:63-68`). Store the profile fields (incl.
`description`, `peerType`) on the reg row at registration so the entry is self-contained.
3. **`generateListing`** (`Listing.hs:148`): merge group entries + contact entries into the single
`DirectoryListing`. Feed the contact rows from `getAllListedContacts` (status active); build
`DirectoryEntry`s from both sources and serialize together. `listingsUpdated` triggers stay as-is,
plus fire on contact-reg status changes.
4. **Website `directory.jsc`**: branch `displayEntries` on `entryType.type` and, for contacts, on
`entryType.peerType`:
- business vs bot label/avatar from `peerType` (`business`/`bot`); non-group avatar fallback
instead of `/img/group.svg`;
- "Connect"/"Chat" affordance instead of the "N members/subscribers" line (`entryMemberCount`
already returns 0 for non-group — `directory.jsc:183-193`);
- join URI already works via `connShortLink` (`directory.jsc:331-348`).
Search/filter already reads generic fields (`displayName`, `shortDescr`, `welcomeMessage`,
`simplexName`), so text search works unchanged.
### E. Bot search
Include active contact regs in the bot's search results (`DCSearchGroup` path,
`Service.hs:1115`, backed by `searchListedGroups` in `Store.hs`) as **one unified result set** (not a
separate contact search); match on display name and SimpleX name.
### F. Tests
- **Client** (`tests/ChatTests/`): `/share address` produces an `MCChat`/`MCLContact` card with a
valid `ownerSig` (`ownerId = Nothing`); parser test for `/share address`.
- **Directory** (`tests/Bots/DirectoryTests.hs`, mirroring `testRegisterChannelViaCard`
`:2050` and `testDirectoryChannelName` `:2129`): register a business and a bot via card
(verified → pending → admin approve → listed), reject on bad/absent signature, search finds it,
and the generated `listing.json` contains a `"type":"contact"` entry with the right `peerType`
(`business`/`bot`). Wire under the names/SMP test harness as needed.
- **Profile description** (§G): a member's `description` is **redacted per the group's policy** in
the profile others receive in a group (send side) and when stored from an incoming member profile
(receive side) — links/names stripped when the group prohibits them, clean prose passing through;
a direct contact / address preview keeps it full.
### G. `Profile.description` field + member-profile redaction (resolved)
`description :: Maybe Text` is added to `Profile` (§A). In group-member profiles it is **redacted
per the group's policy — the same treatment `shortDescr` gets today** (not removed wholesale):
links and SimpleX names are stripped when the group prohibits them.
1. **Send side** — in `redactedMemberProfile` (`Internal.hs:1266-1277`, which already redacts
`shortDescr`/`contactLink`/name-proof under the group's `SGFSimplexLinks`/`SGFDirectMessages`),
also redact `description` — with a **new inline-strip helper** (per G.3), not `shortDescr`'s
drop-whole `removeSimplexLink`. Adding `description` to `Profile` forces this output record to be
rebuilt here anyway. (Used on every member-profile-out path — `Internal.hs:1254,1262`,
`Subscriber.hs:851,3273`, `Commands.hs:4134`.)
2. **Receive side** — apply the same redaction when ingesting a member profile from the network, so
a peer can't inject a link/name-laden description. Chokepoints: `updateMemberProfile`
(`Store/Groups.hs:3407`) and member creation (`Store/Groups.hs:2510`, `1395`); prefer a single
helper mirroring the send-side redaction.
3. **Redaction granularity (RESOLVED).** **Inline-strip links and names** — drop the
`Uri`/`HyperLink`/`SimplexLink`/`SimplexName` (the `isLink` set, `Markdown.hs:184`) and `Mention`
spans via `parseMaybeMarkdownList`, re-concat the remaining `FormattedText`, keep the prose (empty
result ⇒ `Nothing`). **Exception:** if `hasObfuscatedSimplexLink` matches (a link that can't be
cleanly isolated as a token), drop the **whole** description.
4. **Kept full where wanted** — the address link data (`ContactShortLinkData` embeds the full,
unredacted profile), the direct contact profile view, and the directory listing all carry the
full `description`. Group redaction applies only to member-profile *delivery into a group*, a
separate code path. For the **directory** page, abuse is gated by **admin review** (Q7), not an
automatic filter.
5. **UI/UX** — add a multi-line "Description" field to the profile/address editor (app UI, follow-on
with §B.5). Because the field can carry into groups (redacted), an edit-time hint that links and
names won't show where a group prohibits them is worthwhile, mirroring `shortDescr`.
### H. App visibility of `peerType` + `description` (why owners will set them)
These are persistent profile identity shown to everyone who reaches the address — independent of the
directory. That is the reason to fill them in; the directory is a bonus channel. Existing surfaces
(multiplatform paths; iOS/Android mirror them):
**`peerType` — type icon / badge** (small, already-present surfaces):
- Pre-connect "Open chat?" alert (`newchat/ConnectPlan.kt:698-713`) — type icon + verification;
briefcase when **either** the address `business` flag or `peerType == CPTBusiness`, bot cube from
`peerType`, else person (see §1). The alert holds no description (too small — `AlertManager.kt:289`).
- Chat list (`chatlist/ChatPreviewView.kt:188`, `isBot`) and the chat banner
(`chat/ChatView.kt:2227` `ChatBannerView`, which already has per-type captions — bot / business /
contact) — extend to a business marker from `peerType`.
**`description` — shown via a "Read more" affordance, NOT inline** (the alert and the in-chat link
card `CIChatLinkHeader.kt` are too small — they carry only the short teaser). Rendered in **two
surfaces: the chat banner (`ChatBannerView`) and the contact info page (`ChatInfoView`, `:778`)**:
- Teaser text: if `shortDescr` is present → show `shortDescr`, then a clickable **"Read more"**; if
`shortDescr` is absent → show the first line of `description` truncated to 100 chars with ellipsis
(up to the first line break), then **"Read more"**. "Read more" appears only when a `description`
exists to reveal.
- **"Read more" is a general, extensible `Modal` markdown element.** Add an inline `Format` variant
`Modal {modalName :: Text}` to `Markdown.hs:51` (sibling to `Command`/`Mention`/`SimplexLink`) —
**no `showText`**: the app resolves both the tappable label and the modal content from the current
chat by `modalName` (e.g. `modalName = "description"` → renders "Read more", opens the contact's
`description`). `Format`'s existing `Unknown` fallback (`parseJSON … <|> pure (Unknown v)`,
`Markdown.hs:533`) makes it forward-compat — old apps decode it as `Unknown`. The **teaser is built
app-side** from the profile fields (d3), so the Haskell core just adds the variant + JSON so the
app's mirrored enum matches; each client renders the tap (iOS sheet / Android modal), reusing the
existing tappable-markdown mechanism (no iOS multiline-hit-test hack).
- This is NOT the welcome/auto-reply message (`AddressSettings.autoReply`, a transient on-connect
message), and NOT shown in the pre-connect alert or the shared-link card.
**Profile editor** (`usersettings/UserProfileView.kt`) — add the multi-line description field and a
way to set the account type (`peerType`). Note: the editor exposes two separate "business" concepts
— `peerType` (identity) and the `businessAddress` conversation-type setting — which must use distinct
labels, since both otherwise read as "business."
Note: before connecting, the only surface with room to read the full description is the directory web
page; in-app it is the banner/info "Read more" once the (prepared) chat is open.
## 5. Files to touch (summary)
- `src/Simplex/Chat/Types.hs` — extend `ChatPeerType` (`CPTBusiness`, `CPTUnknown`, lenient decode);
add `Profile.description`; JSON/TextEncoding derivations.
- `src/Simplex/Chat/Markdown.hs` — add the `Modal {modalName}` `Format` variant + JSON (§H).
- App views (Phase 1, §B.5/§H) — `UserAddressView.kt` ("Share via chat" button + upgrade branch),
`ChatInfoView.kt` + `ChatView.kt` `ChatBannerView` (description teaser + `Modal` "Read more"), the
Kotlin/Swift `Format` mirror (`Modal` case + tap → sheet/alert) (+ iOS equivalents). The `peerType`
badge/editor UI is deferred.
- `src/Simplex/Chat/Controller.hs` — `APIShareMyAddress`, `ShareMyAddress` command constructors.
- `src/Simplex/Chat/Library/Commands.hs` — handlers + parsers for the two new commands; reuse
`shareChatBinding`.
- `src/Simplex/Chat/Library/Internal.hs` — redact `description` per group policy in `redactedMemberProfile` (send side, §G).
- `src/Simplex/Chat/Store/Groups.hs` — redact `description` when ingesting a member profile (receive side, §G).
- `src/Simplex/Chat/Store/Profiles.hs` — persist `link_priv_sig_key` on short-link upgrade
(`setUserContactLinkShortLink`/`setMyAddressData`); card signing uses the agent's
`getConnLinkPrivKey`, not this column.
- `apps/simplex-directory-service/src/Directory/Service.hs` — `MCLContact` case in
`deChatLinkReceived`; contact-reg lifecycle + admin/user commands; listing trigger.
- `apps/simplex-directory-service/src/Directory/Store.hs` — `ContactReg` model + queries.
- `apps/simplex-directory-service/src/Directory/Store/{SQLite,Postgres}/Migrations.hs` — new table.
- `apps/simplex-directory-service/src/Directory/Events.hs` — extend `directoryCmdP` to parse the
`@`/`#` chat-type prefix and thread the chat type into the (shared) command constructors.
- `apps/simplex-directory-service/src/Directory/Listing.hs` — `DETContact`, `contactDirectoryEntry`,
merge in `generateListing`.
- `website/src/js/directory.jsc` (+ a contact/bot avatar asset) — non-group card rendering.
- `tests/Bots/DirectoryTests.hs`, `tests/ChatTests/*` — tests.
## 6. Design decisions
Resolved:
- **Submission model (RESOLVED: identical to channels).** Submission is by the **link owner**,
**signed with the address key** — exactly the channel card flow, no extra requirement. The
`ownerSig` (only the key-holder can produce it) is the authorization. Admins then decide to list;
any profile change sends it back to admin review; the address is re-read on the same periodic loop
— all as for channels. The only "submitter ≠ owner" accommodation is giving the headless support
bot a way to send (§B.4). *(Earlier we explored open submission with the verified SimpleX name as
the authenticity signal, and an opt-in flag in the address link data; both dropped — the channel
model already answers authorization, and name-verification proves identity, not consent to list.)*
- **Description home (RESOLVED: `Profile.description`, redacted per group policy).** New profile
field. In group-member profiles it is redacted the same way `shortDescr` is (links/names stripped
under the group's policy, §G), not removed wholesale; carried full in the address link data /
direct view / directory. Directory abuse is gated by admin review (Q7).
- **Q1 — Verification (RESOLVED: reuse the plan).** Already verifiable via `APIConnectPlan` — the
same `verifyLinkOwner` path channels use, no new code. The intrinsic link-data fetch (to get the
root public key) is opaque and not a connection. No card/protocol extension.
- **Q4 — Command surface (RESOLVED: same commands, extended with chat type).** Reuse the existing
command constructors and syntax with a chat-type discriminator on the id token — `#` group
(existing), `@` contact (new) — e.g. `/approve @<id>:<name> <version>`. The prefix disambiguates
the overlapping `group_id`/`contact_reg_id` spaces, so no parallel command names are needed.
- **Q5 — SimpleX names (RESOLVED: support now).** Verify name↔link consistency for addresses and
populate `simplex_name`; flows through to listing + search (see §C.5).
- **Q2 — Entry type (RESOLVED: `ChatPeerType`, typed + admin-verified).** The listing type is a
`ChatPeerType`: **bot** from `peerType == CPTBot`; **business** from `peerType ∈ {CPTHuman,
CPTBusiness}` (unset ≙ `CPTHuman`; stored as `CPTBusiness`); `CPTUnknown` is rejected. Because
`CPTBusiness` can't be published on profiles yet (wire-compat, §A), a business's profile is
`CPTHuman` in practice; the admin verifies it. When profiles can carry `CPTBusiness`, it's read
directly.
- **Q3 — Reg status type (RESOLVED: reuse the group/channel type).** Use the same `GroupRegStatus`
the channel registrations use — no separate `ContactRegStatus`. The lifecycle mirrors channels.
- **Q6 — Updates (RESOLVED: re-read in the same loop as channels).** The directory re-reads the
registered address links in the same periodic link-check loop it runs for channels
(`deGroupLinkCheck`, `Service.hs:832`), re-fetching the address link data to pick up profile/link
changes and re-verify the name. Opaque fetch, no connection.
- **Q7 — Description screening (RESOLVED: two surfaces, two mechanisms).** *Directory page:* admin
approval is the gate — a profile change (incl. description) triggers re-approval, hiding the
address until re-approved, exactly like a channel profile change; no separate automatic content
filter on the directory description. *Group member profiles:* the group's own policy redacts the
description on delivery (links/names stripped like `shortDescr`, §G). The two are independent.
## 7. Suggested sequencing
**Phase 1 — UX prerequisites (self-contained; do these first — no registration work until they
land).**
1. **`Profile.description` field** (§A) + member-profile redaction on send and receive (§G) + a test
that a member's description is redacted per group policy.
2. **Show the description in the app** — banner + contact-info "Read more" via the `Modal` markdown
element (§H). This is the "see how it looks" step; iterate on the UX here.
3. **`ChatPeerType` extension** (`CPTBusiness`, `CPTUnknown`, lenient decoder) (§A) — the type only,
**no UI** to set or display it yet.
4. **Share a contact link via chat** — core (`getUserAddressSignKey`, `APIShareAddress`,
`/share address`, §B.13) + the app share UI mirroring the channel share (§B.5) + a client test on
the signed `MCLContact` card.
**Phase 2 — directory (only after Phase 1).**
5. Directory store: migration + `ContactReg` model/queries (link-keyed).
6. `deChatLinkReceived` `MCLContact` case (verify + derive type + link-in-profile check +
`addContactReg`) + name↔link verification + admin approval + directory test through to "listed".
7. Listing merge (`DETContact` + `contactDirectoryEntry` + `generateListing`) + one unified
group+contact search + website rendering.
**Deferred:** peerType setting/badge UI; the support-bot entry point (§B.4).
+2
View File
@@ -148,6 +148,7 @@ library
Simplex.Chat.Store.Postgres.Migrations.M20260603_simplex_name
Simplex.Chat.Store.Postgres.Migrations.M20260629_roster_catchup
Simplex.Chat.Store.Postgres.Migrations.M20260707_file_digest
Simplex.Chat.Store.Postgres.Migrations.M20260714_contact_description
else
exposed-modules:
Simplex.Chat.Archive
@@ -313,6 +314,7 @@ library
Simplex.Chat.Store.SQLite.Migrations.M20260603_simplex_name
Simplex.Chat.Store.SQLite.Migrations.M20260629_roster_catchup
Simplex.Chat.Store.SQLite.Migrations.M20260707_file_digest
Simplex.Chat.Store.SQLite.Migrations.M20260714_contact_description
other-modules:
Paths_simplex_chat
hs-source-dirs:
+1 -1
View File
@@ -140,7 +140,7 @@ createActiveUser cc CoreChatOpts {chatRelay} = \case
displayName <- T.pack <$> withPrompt "display name" getLine
createUser loop False $ mkProfile displayName
where
mkProfile displayName = Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, contactDomain = Nothing}
mkProfile displayName = Profile {displayName, fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, contactDomain = Nothing}
createUser onError clientService p =
execChatCommand' (CreateActiveUser NewUser {profile = Just p, pastTimestamp = False, userChatRelay = BoolDef chatRelay, clientService = BoolDef clientService}) 0 `runReaderT` cc >>= \case
Right (CRActiveUser user) -> pure user
+2 -2
View File
@@ -5746,7 +5746,7 @@ chatCommandP =
newUserP relay = do
(cName, shortDescr) <- profileNameDescr
service <- (" service=" *> onOffP) <|> pure False
let profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, contactDomain = Nothing}
let profile = Just Profile {displayName = cName, fullName = "", shortDescr, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, contactDomain = Nothing}
pure NewUser {profile, pastTimestamp = False, userChatRelay = BoolDef relay, clientService = BoolDef service}
newBotUserP = do
files_ <- optional $ "files=" *> onOffP <* A.space
@@ -5755,7 +5755,7 @@ chatCommandP =
let preferences = case files_ of
Just True -> Nothing
_ -> Just (emptyChatPrefs :: Preferences) {files = Just FilesPreference {allow = FANo}}
profile = Just Profile {displayName = cName, fullName = "", shortDescr, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences, badge = Nothing, contactDomain = Nothing}
profile = Just Profile {displayName = cName, fullName = "", shortDescr, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences, badge = Nothing, contactDomain = Nothing}
pure NewUser {profile, pastTimestamp = False, userChatRelay = BoolDef False, clientService = BoolDef service}
jsonP :: J.FromJSON a => Parser a
jsonP = J.eitherDecodeStrict' <$?> A.takeByteString
File diff suppressed because one or more lines are too long
+2 -1
View File
@@ -2832,7 +2832,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
when contentChanged $ updateBusinessChatProfile gInfo
case memberContactId of
Nothing -> do
m' <- withStore $ \db -> updateMemberProfile db cxt user m p'
m' <- withStore $ \db -> updateMemberProfile db cxt user m pr'
unless (muteEventInChannel gInfo m') $ do
when contentChanged $ forM_ msgTs_ $ createProfileUpdatedItem m'
toView $ CEvtGroupMemberUpdated user gInfo m m'
@@ -2857,6 +2857,7 @@ processAgentMessageConn cxt user@User {userId} corrId agentConnId agentMessage =
| otherwise =
pure m
where
pr' = redactMemberProfileDescription gInfo m p'
contentChanged = not (sameProfileContent (redactedMemberProfile gInfo m (fromLocalProfile p)) (redactedMemberProfile gInfo m p'))
updateBusinessChatProfile g@GroupInfo {businessChat} = case businessChat of
Just bc | isMainBusinessMember bc m -> do
+1 -1
View File
@@ -10,7 +10,7 @@ generateRandomProfile :: IO Profile
generateRandomProfile = do
adjective <- pick adjectives
noun <- pickNoun adjective 2
pure $ Profile {displayName = adjective <> noun, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, contactDomain = Nothing}
pure $ Profile {displayName = adjective <> noun, fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = Nothing, badge = Nothing, contactDomain = Nothing}
where
pick :: [a] -> IO a
pick xs = (xs !!) <$> randomRIO (0, length xs - 1)
+5 -5
View File
@@ -112,7 +112,7 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do
db
[sql|
SELECT
c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite,
c.contact_profile_id, c.local_display_name, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, c.contact_used, c.contact_status, c.enable_ntfs, c.send_rcpts, c.favorite,
p.preferences, c.user_preferences, c.created_at, c.updated_at, c.chat_ts, c.conn_full_link_to_connect, c.conn_short_link_to_connect, c.welcome_shared_msg_id, c.request_shared_msg_id, c.contact_request_id,
c.contact_group_member_id, c.contact_grp_inv_sent, c.grp_direct_inv_link, c.grp_direct_inv_from_group_id, c.grp_direct_inv_from_group_member_id, c.grp_direct_inv_from_member_conn_id, c.grp_direct_inv_started_connection,
c.ui_themes, c.chat_deleted, c.custom_data, c.chat_item_ttl,
@@ -124,8 +124,8 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do
|]
(userId, contactId, CSActive)
toContact' :: UTCTime -> Int64 -> Connection -> [ChatTagId] -> ContactRow' -> Contact
toContact' currentTs contactId conn chatTags ((profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow :. domainRow) =
let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge currentTs badgeRow, preferences, localAlias}
toContact' currentTs contactId conn chatTags ((profileId, localDisplayName, displayName, fullName, shortDescr, description, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow :. domainRow) =
let profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge currentTs badgeRow, preferences, localAlias}
chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite}
mergedPreferences = contactUserPreferences user userPreferences preferences $ connIncognito conn
activeConn = Just conn
@@ -156,13 +156,13 @@ getConnectionEntity db cxt user@User {userId, userContactId} agentConnId = do
mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category,
mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id,
-- GroupInfo {membership = GroupMember {memberProfile}}
pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences,
pu.display_name, pu.full_name, pu.short_descr, pu.description, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences,
pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, pu.contact_domain, pu.contact_domain_proof, pu.contact_domain_verified,
mu.created_at, mu.updated_at,
mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link,
-- from GroupMember
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link
+7 -6
View File
@@ -73,7 +73,7 @@ createOrUpdateContactRequest
isSimplexTeam
invId
cReqChatVRange@(VersionRange minV maxV)
profile@Profile {displayName, fullName, shortDescr, image, contactLink, badge, preferences}
profile@Profile {displayName, fullName, shortDescr, description, image, contactLink, badge, preferences}
xContactId_
welcomeMsgId_
requestMsg_
@@ -112,7 +112,7 @@ createOrUpdateContactRequest
[sql|
SELECT
-- Contact
ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite,
ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.description, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite,
cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id,
ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection,
ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl,
@@ -148,7 +148,7 @@ createOrUpdateContactRequest
SELECT
cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id,
cr.contact_id, cr.business_group_id, cr.user_contact_link_id,
cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id,
cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id,
cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences,
cr.created_at, cr.updated_at,
cr.peer_chat_min_version, cr.peer_chat_max_version,
@@ -168,8 +168,8 @@ createOrUpdateContactRequest
liftIO $
DB.execute
db
"INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
((displayName, fullName, shortDescr, image, contactLink, userId) :. ("" :: LocalAlias, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified)
"INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
((displayName, fullName, shortDescr, description, image, contactLink, userId) :. ("" :: LocalAlias, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified)
profileId <- liftIO $ insertedRowId db
liftIO $
DB.execute
@@ -238,6 +238,7 @@ createOrUpdateContactRequest
SET display_name = ?,
full_name = ?,
short_descr = ?,
description = ?,
image = ?,
contact_link = ?,
updated_at = ?,
@@ -257,7 +258,7 @@ createOrUpdateContactRequest
AND contact_request_id = ?
)
|]
((displayName, fullName, shortDescr, image, contactLink, currentTs) :. badgeToRow badge badgeVerified :. (userId, contactRequestId))
((displayName, fullName, shortDescr, description, image, contactLink, currentTs) :. badgeToRow badge badgeVerified :. (userId, contactRequestId))
updateRequest currentTs =
if displayName == oldDisplayName
then
+12 -12
View File
@@ -321,7 +321,7 @@ getContactByConnReqHash db cxt user@User {userId} cReqHash1 cReqHash2 = do
[sql|
SELECT
-- Contact
ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite,
ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.description, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite,
cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id,
ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection,
ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl,
@@ -731,17 +731,17 @@ updateContactProfile_ db userId profileId profile badgeVerified = do
updateContactProfile_' db userId profileId profile badgeVerified currentTs
updateContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> UTCTime -> IO ()
updateContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image, contactLink, contactDomain, preferences, peerType, badge} badgeVerified updatedAt =
updateContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, description, image, contactLink, contactDomain, preferences, peerType, badge} badgeVerified updatedAt =
DB.execute
db
[sql|
UPDATE contact_profiles
SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ?,
SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ?,
badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?,
contact_domain = ?, contact_domain_proof = ?
WHERE user_id = ? AND contact_profile_id = ?
|]
((displayName, fullName, shortDescr, image, contactLink, preferences, peerType, updatedAt) :. badgeToRow badge badgeVerified :. contactDomainToRow contactDomain :. (userId, profileId))
((displayName, fullName, shortDescr, description, image, contactLink, preferences, peerType, updatedAt) :. badgeToRow badge badgeVerified :. contactDomainToRow contactDomain :. (userId, profileId))
-- update only member profile fields (when member doesn't have associated contact - we can reset contactLink and prefs)
updateMemberContactProfileReset_ :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> IO ()
@@ -750,17 +750,17 @@ updateMemberContactProfileReset_ db userId profileId profile badgeVerified = do
updateMemberContactProfileReset_' db userId profileId profile badgeVerified currentTs
updateMemberContactProfileReset_' :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> UTCTime -> IO ()
updateMemberContactProfileReset_' db userId profileId Profile {displayName, fullName, shortDescr, image, contactDomain, badge} badgeVerified updatedAt =
updateMemberContactProfileReset_' db userId profileId Profile {displayName, fullName, shortDescr, description, image, contactDomain, badge} badgeVerified updatedAt =
DB.execute
db
[sql|
UPDATE contact_profiles
SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ?,
SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = NULL, preferences = NULL, updated_at = ?,
badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?,
contact_domain = ?, contact_domain_proof = ?
WHERE user_id = ? AND contact_profile_id = ?
|]
((displayName, fullName, shortDescr, image, updatedAt) :. badgeToRow badge badgeVerified :. contactDomainToRow contactDomain :. (userId, profileId))
((displayName, fullName, shortDescr, description, image, updatedAt) :. badgeToRow badge badgeVerified :. contactDomainToRow contactDomain :. (userId, profileId))
-- update only member profile fields (when member has associated contact - we keep contactLink and prefs)
updateMemberContactProfile_ :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> IO ()
@@ -769,17 +769,17 @@ updateMemberContactProfile_ db userId profileId profile badgeVerified = do
updateMemberContactProfile_' db userId profileId profile badgeVerified currentTs
updateMemberContactProfile_' :: DB.Connection -> UserId -> ProfileId -> Profile -> Maybe Bool -> UTCTime -> IO ()
updateMemberContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, image, contactDomain, badge} badgeVerified updatedAt =
updateMemberContactProfile_' db userId profileId Profile {displayName, fullName, shortDescr, description, image, contactDomain, badge} badgeVerified updatedAt =
DB.execute
db
[sql|
UPDATE contact_profiles
SET display_name = ?, full_name = ?, short_descr = ?, image = ?, updated_at = ?,
SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, updated_at = ?,
badge_proof = ?, badge_pres_header = ?, badge_expiry = ?, badge_type = ?, badge_verified = ?, badge_extra = ?, badge_master_key = ?, badge_signature = ?, badge_key_idx = ?,
contact_domain = ?, contact_domain_proof = ?
WHERE user_id = ? AND contact_profile_id = ?
|]
((displayName, fullName, shortDescr, image, updatedAt) :. badgeToRow badge badgeVerified :. contactDomainToRow contactDomain :. (userId, profileId))
((displayName, fullName, shortDescr, description, image, updatedAt) :. badgeToRow badge badgeVerified :. contactDomainToRow contactDomain :. (userId, profileId))
updateContactLDN_ :: DB.Connection -> User -> Int64 -> ContactName -> ContactName -> UTCTime -> IO ()
updateContactLDN_ db user@User {userId} contactId displayName newName updatedAt = do
@@ -849,7 +849,7 @@ contactRequestQuery =
SELECT
cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id,
cr.contact_id, cr.business_group_id, cr.user_contact_link_id,
cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id,
cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id,
cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences,
cr.created_at, cr.updated_at,
cr.peer_chat_min_version, cr.peer_chat_max_version,
@@ -970,7 +970,7 @@ getContact_ db cxt user@User {userId} contactId deleted = do
[sql|
SELECT
-- Contact
ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite,
ct.contact_id, ct.contact_profile_id, ct.local_display_name, cp.display_name, cp.full_name, cp.short_descr, cp.description, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, ct.contact_used, ct.contact_status, ct.enable_ntfs, ct.send_rcpts, ct.favorite,
cp.preferences, ct.user_preferences, ct.created_at, ct.updated_at, ct.chat_ts, ct.conn_full_link_to_connect, ct.conn_short_link_to_connect, ct.welcome_shared_msg_id, ct.request_shared_msg_id, ct.contact_request_id,
ct.contact_group_member_id, ct.contact_grp_inv_sent, ct.grp_direct_inv_link, ct.grp_direct_inv_from_group_id, ct.grp_direct_inv_from_group_member_id, ct.grp_direct_inv_from_member_conn_id, ct.grp_direct_inv_started_connection,
ct.ui_themes, ct.chat_deleted, ct.custom_data, ct.chat_item_ttl,
+9 -9
View File
@@ -259,11 +259,11 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..))
import Database.SQLite.Simple.QQ (sql)
#endif
type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. ((Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. BadgeRow :. ContactDomainRow) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact)
type MaybeGroupMemberRow = (Maybe GroupMemberId, Maybe GroupId, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId) :. ((Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe LocalAlias, Maybe Preferences) :. BadgeRow :. ContactDomainRow) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64, Maybe UTCTime, Maybe C.PublicKeyEd25519, Maybe ShortLinkContact)
toMaybeGroupMember :: UTCTime -> Int64 -> MaybeGroupMemberRow -> Maybe GroupMember
toMaybeGroupMember now userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. ((Just profileId, Just displayName, Just fullName, shortDescr, image, contactLink, peerType, Just localAlias, contactPreferences) :. badgeRow :. domainRow) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) =
Just $ toGroupMember now userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. ((profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, contactPreferences) :. badgeRow :. domainRow) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink))
toMaybeGroupMember now userContactId ((Just groupMemberId, Just groupId, Just indexInGroup, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId) :. ((Just profileId, Just displayName, Just fullName, shortDescr, description, image, contactLink, peerType, Just localAlias, contactPreferences) :. badgeRow :. domainRow) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink)) =
Just $ toGroupMember now userContactId ((groupMemberId, groupId, indexInGroup, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked') :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId) :. ((profileId, displayName, fullName, shortDescr, description, image, contactLink, peerType, localAlias, contactPreferences) :. badgeRow :. domainRow) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions, supportChatLastMsgFromMemberTs, memberPubKey, relayLink))
toMaybeGroupMember _ _ _ = Nothing
createGroupLink :: DB.Connection -> TVar ChaChaDRG -> User -> GroupInfo -> ConnId -> CreatedLinkContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO GroupLink
@@ -2062,7 +2062,7 @@ createJoiningMember
User {userId, userContactId}
GroupInfo {groupId, membership}
cReqChatVRange
Profile {displayName, fullName, shortDescr, image, contactLink, badge, preferences}
Profile {displayName, fullName, shortDescr, description, image, contactLink, badge, preferences}
cReqXContactId_
cReqMemberId_
welcomeMsgId_
@@ -2075,8 +2075,8 @@ createJoiningMember
liftIO $
DB.execute
db
"INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
((displayName, fullName, shortDescr, image, contactLink, userId, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified)
"INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
((displayName, fullName, shortDescr, description, image, contactLink, userId, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified)
profileId <- liftIO $ insertedRowId db
case cReqMemberId_ of
Just memberId -> do
@@ -2443,13 +2443,13 @@ createNewGroupMember db cxt user gInfo invitingMember memInfo@MemberInfo {profil
createNewMember_ db user gInfo newMember badgeVerified currentTs
createNewMemberProfile_ :: DB.Connection -> StoreCxt -> User -> Profile -> UTCTime -> ExceptT StoreError IO (Text, ProfileId, Maybe Bool)
createNewMemberProfile_ db cxt User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, badge, preferences} createdAt =
createNewMemberProfile_ db cxt User {userId} Profile {displayName, fullName, shortDescr, description, image, contactLink, badge, preferences} createdAt =
ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do
badgeVerified <- verifyBadge_ (badgeKeys cxt) badge
DB.execute
db
"INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, user_id, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
((displayName, fullName, shortDescr, image, contactLink, userId, preferences, createdAt, createdAt) :. badgeToRow badge badgeVerified)
"INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, user_id, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
((displayName, fullName, shortDescr, description, image, contactLink, userId, preferences, createdAt, createdAt) :. badgeToRow badge badgeVerified)
profileId <- insertedRowId db
pure $ Right (ldn, profileId, badgeVerified)
+5 -5
View File
@@ -714,7 +714,7 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe
-- GroupMember
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category,
m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id,
p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link
@@ -1135,7 +1135,7 @@ getContactRequestChatPreviews_ db User {userId} pagination clq = do
SELECT
cr.contact_request_id, cr.local_display_name, cr.agent_invitation_id,
cr.contact_id, cr.business_group_id, cr.user_contact_link_id,
cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id,
cr.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, cr.xcontact_id,
cr.pq_support, cr.welcome_shared_msg_id, cr.request_shared_msg_id, p.preferences,
cr.created_at, cr.updated_at,
cr.peer_chat_min_version, cr.peer_chat_max_version,
@@ -3069,7 +3069,7 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do
-- GroupMember
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category,
m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id,
p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link,
@@ -3078,14 +3078,14 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do
-- quoted GroupMember
rm.group_member_id, rm.group_id, rm.index_in_group, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category,
rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id,
rp.display_name, rp.full_name, rp.short_descr, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences,
rp.display_name, rp.full_name, rp.short_descr, rp.description, rp.image, rp.contact_link, rp.chat_peer_type, rp.local_alias, rp.preferences,
rp.badge_proof, rp.badge_pres_header, rp.badge_expiry, rp.badge_type, rp.badge_verified, rp.badge_extra, rp.badge_master_key, rp.badge_signature, rp.badge_key_idx, rp.contact_domain, rp.contact_domain_proof, rp.contact_domain_verified,
rm.created_at, rm.updated_at,
rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, rm.support_chat_last_msg_from_member_ts, rm.member_pub_key, rm.relay_link,
-- deleted by GroupMember
dbm.group_member_id, dbm.group_id, dbm.index_in_group, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category,
dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id,
dbp.display_name, dbp.full_name, dbp.short_descr, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences,
dbp.display_name, dbp.full_name, dbp.short_descr, dbp.description, dbp.image, dbp.contact_link, dbp.chat_peer_type, dbp.local_alias, dbp.preferences,
dbp.badge_proof, dbp.badge_pres_header, dbp.badge_expiry, dbp.badge_type, dbp.badge_verified, dbp.badge_extra, dbp.badge_master_key, dbp.badge_signature, dbp.badge_key_idx, dbp.contact_domain, dbp.contact_domain_proof, dbp.contact_domain_verified,
dbm.created_at, dbm.updated_at,
dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions, dbm.support_chat_last_msg_from_member_ts, dbm.member_pub_key, dbm.relay_link
@@ -41,6 +41,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260602_group_roster
import Simplex.Chat.Store.Postgres.Migrations.M20260603_simplex_name
import Simplex.Chat.Store.Postgres.Migrations.M20260629_roster_catchup
import Simplex.Chat.Store.Postgres.Migrations.M20260707_file_digest
import Simplex.Chat.Store.Postgres.Migrations.M20260714_contact_description
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Text, Maybe Text)]
@@ -81,7 +82,8 @@ schemaMigrations =
("20260602_group_roster", m20260602_group_roster, Just down_m20260602_group_roster),
("20260603_simplex_name", m20260603_simplex_name, Just down_m20260603_simplex_name),
("20260629_roster_catchup", m20260629_roster_catchup, Just down_m20260629_roster_catchup),
("20260707_file_digest", m20260707_file_digest, Just down_m20260707_file_digest)
("20260707_file_digest", m20260707_file_digest, Just down_m20260707_file_digest),
("20260714_contact_description", m20260714_contact_description, Just down_m20260714_contact_description)
]
-- | The list of migrations in ascending order by date
@@ -0,0 +1,21 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.Postgres.Migrations.M20260714_contact_description where
import Data.Text (Text)
import qualified Data.Text as T
import Text.RawString.QQ (r)
m20260714_contact_description :: Text
m20260714_contact_description =
T.pack
[r|
ALTER TABLE contact_profiles ADD COLUMN description TEXT;
|]
down_m20260714_contact_description :: Text
down_m20260714_contact_description =
T.pack
[r|
ALTER TABLE contact_profiles DROP COLUMN description;
|]
+10 -10
View File
@@ -133,7 +133,7 @@ import Database.SQLite.Simple.QQ (sql)
#endif
createUserRecordAt :: DB.Connection -> AgentUserId -> Bool -> Bool -> Profile -> Bool -> UTCTime -> ExceptT StoreError IO User
createUserRecordAt db (AgentUserId auId) userChatRelay clientService Profile {displayName, fullName, shortDescr, image, peerType, preferences = userPreferences} activeUser currentTs =
createUserRecordAt db (AgentUserId auId) userChatRelay clientService Profile {displayName, fullName, shortDescr, description, image, peerType, preferences = userPreferences} activeUser currentTs =
checkConstraint SEDuplicateName . liftIO $ do
when activeUser $ DB.execute_ db "UPDATE users SET active_user = 0"
let showNtfs = True
@@ -154,8 +154,8 @@ createUserRecordAt db (AgentUserId auId) userChatRelay clientService Profile {di
(displayName, displayName, userId, currentTs, currentTs)
DB.execute
db
"INSERT INTO contact_profiles (display_name, full_name, short_descr, image, chat_peer_type, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?)"
(displayName, fullName, shortDescr, image, peerType, userId, userPreferences, currentTs, currentTs)
"INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, chat_peer_type, user_id, preferences, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)"
(displayName, fullName, shortDescr, description, image, peerType, userId, userPreferences, currentTs, currentTs)
profileId <- insertedRowId db
DB.execute
db
@@ -163,7 +163,7 @@ createUserRecordAt db (AgentUserId auId) userChatRelay clientService Profile {di
(profileId, displayName, userId, BI True, currentTs, currentTs, currentTs)
contactId <- insertedRowId db
DB.execute db "UPDATE users SET contact_id = ? WHERE user_id = ?" (contactId, userId)
pure $ toUser currentTs $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, Nothing, Nothing, Nothing, BI userChatRelay, BI clientService, Nothing) :. localBadgeToRow Nothing :. (Nothing, Nothing, Nothing)
pure $ toUser currentTs $ (userId, auId, contactId, profileId, BI activeUser, order) :. (displayName, fullName, shortDescr, description, image, Nothing, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, Nothing, Nothing, Nothing, BI userChatRelay, BI clientService, Nothing) :. localBadgeToRow Nothing :. (Nothing, Nothing, Nothing)
-- TODO [mentions]
getUsersInfo :: DB.Connection -> IO [UserInfo]
@@ -360,15 +360,15 @@ updateUserProfile db user p'
-- own profile field update; leaves the badge columns alone (the credential is owned by setUserBadge/addUserBadge)
updateUserProfileFields_' :: DB.Connection -> UserId -> ProfileId -> Profile -> UTCTime -> IO ()
updateUserProfileFields_' db userId profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType} updatedAt =
updateUserProfileFields_' db userId profileId Profile {displayName, fullName, shortDescr, description, image, contactLink, preferences, peerType} updatedAt =
DB.execute
db
[sql|
UPDATE contact_profiles
SET display_name = ?, full_name = ?, short_descr = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ?
SET display_name = ?, full_name = ?, short_descr = ?, description = ?, image = ?, contact_link = ?, preferences = ?, chat_peer_type = ?, updated_at = ?
WHERE user_id = ? AND contact_profile_id = ?
|]
((displayName, fullName, shortDescr, image, contactLink, preferences, peerType, updatedAt) :. (userId, profileId))
((displayName, fullName, shortDescr, description, image, contactLink, preferences, peerType, updatedAt) :. (userId, profileId))
-- store the user's own badge credential; touches only the badge columns.
-- bumps user_member_profile_updated_at so groups receive the updated profile (with the badge) on the next message.
@@ -417,14 +417,14 @@ getUserContactProfiles db User {userId} =
<$> DB.query
db
[sql|
SELECT display_name, full_name, short_descr, image, contact_link, chat_peer_type, contact_domain, preferences
SELECT display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, contact_domain, preferences
FROM contact_profiles
WHERE user_id = ?
|]
(Only userId)
where
toContactProfile :: (ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe SimplexDomain, Maybe Preferences) -> Profile
toContactProfile (displayName, fullName, shortDescr, image, contactLink, peerType, domain_, preferences) = Profile {displayName, fullName, shortDescr, image, contactLink, contactDomain = mkDomainClaim <$> domain_, peerType, preferences, badge = Nothing}
toContactProfile :: (ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe SimplexDomain, Maybe Preferences) -> Profile
toContactProfile (displayName, fullName, shortDescr, description, image, contactLink, peerType, domain_, preferences) = Profile {displayName, fullName, shortDescr, description, image, contactLink, contactDomain = mkDomainClaim <$> domain_, peerType, preferences, badge = Nothing}
createUserContactLink :: DB.Connection -> User -> ConnId -> CreatedLinkContact -> SubscriptionMode -> C.PrivateKeyEd25519 -> ExceptT StoreError IO ()
createUserContactLink db User {userId} agentConnId (CCLink cReq shortLink) subMode linkPrivSigKey =
+3 -1
View File
@@ -164,6 +164,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260602_group_roster
import Simplex.Chat.Store.SQLite.Migrations.M20260603_simplex_name
import Simplex.Chat.Store.SQLite.Migrations.M20260629_roster_catchup
import Simplex.Chat.Store.SQLite.Migrations.M20260707_file_digest
import Simplex.Chat.Store.SQLite.Migrations.M20260714_contact_description
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -327,7 +328,8 @@ schemaMigrations =
("20260602_group_roster", m20260602_group_roster, Just down_m20260602_group_roster),
("20260603_simplex_name", m20260603_simplex_name, Just down_m20260603_simplex_name),
("20260629_roster_catchup", m20260629_roster_catchup, Just down_m20260629_roster_catchup),
("20260707_file_digest", m20260707_file_digest, Just down_m20260707_file_digest)
("20260707_file_digest", m20260707_file_digest, Just down_m20260707_file_digest),
("20260714_contact_description", m20260714_contact_description, Just down_m20260714_contact_description)
]
-- | The list of migrations in ascending order by date
@@ -0,0 +1,18 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.SQLite.Migrations.M20260714_contact_description where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
m20260714_contact_description :: Query
m20260714_contact_description =
[sql|
ALTER TABLE contact_profiles ADD COLUMN description TEXT;
|]
down_m20260714_contact_description :: Query
down_m20260714_contact_description =
[sql|
ALTER TABLE contact_profiles DROP COLUMN description;
|]
@@ -31,7 +31,8 @@ CREATE TABLE contact_profiles(
badge_key_idx INTEGER,
contact_domain TEXT,
contact_domain_proof TEXT,
contact_domain_verified INTEGER
contact_domain_verified INTEGER,
description TEXT
) STRICT;
CREATE TABLE users(
user_id INTEGER PRIMARY KEY,
+23 -23
View File
@@ -321,14 +321,14 @@ createConnection_ db userId connType entityId acId connStatus connChatVersion pe
ent ct = if connType == ct then entityId else Nothing
createIncognitoProfile_ :: DB.Connection -> UserId -> UTCTime -> Profile -> IO Int64
createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, shortDescr, image} = do
createIncognitoProfile_ db userId createdAt Profile {displayName, fullName, shortDescr, description, image} = do
DB.execute
db
[sql|
INSERT INTO contact_profiles (display_name, full_name, short_descr, image, user_id, incognito, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?)
INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, user_id, incognito, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?)
|]
(displayName, fullName, shortDescr, image, userId, Just (BI True), createdAt, createdAt)
(displayName, fullName, shortDescr, description, image, userId, Just (BI True), createdAt, createdAt)
insertedRowId db
updateConnSupportPQ :: DB.Connection -> Int64 -> PQSupport -> PQEncryption -> IO ()
@@ -415,13 +415,13 @@ createContact db cxt user profile = do
void $ createContact_ db cxt user profile emptyChatPrefs Nothing "" currentTs
createContact_ :: DB.Connection -> StoreCxt -> User -> Profile -> Preferences -> Maybe (ACreatedConnLink, Maybe SharedMsgId) -> LocalAlias -> UTCTime -> ExceptT StoreError IO ContactId
createContact_ db cxt User {userId} Profile {displayName, fullName, shortDescr, image, contactLink, contactDomain, peerType, badge, preferences} ctUserPreferences prepared localAlias currentTs =
createContact_ db cxt User {userId} Profile {displayName, fullName, shortDescr, description, image, contactLink, contactDomain, peerType, badge, preferences} ctUserPreferences prepared localAlias currentTs =
ExceptT . withLocalDisplayName db userId displayName $ \ldn -> do
badgeVerified <- verifyBadge_ (badgeKeys cxt) badge
DB.execute
db
"INSERT INTO contact_profiles (display_name, full_name, short_descr, image, contact_link, chat_peer_type, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, contact_domain, contact_domain_proof) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
((displayName, fullName, shortDescr, image, contactLink, peerType) :. (userId, localAlias, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified :. contactDomainToRow contactDomain)
"INSERT INTO contact_profiles (display_name, full_name, short_descr, description, image, contact_link, chat_peer_type, user_id, local_alias, preferences, created_at, updated_at, badge_proof, badge_pres_header, badge_expiry, badge_type, badge_verified, badge_extra, badge_master_key, badge_signature, badge_key_idx, contact_domain, contact_domain_proof) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"
((displayName, fullName, shortDescr, description, image, contactLink, peerType) :. (userId, localAlias, preferences, currentTs, currentTs) :. badgeToRow badge badgeVerified :. contactDomainToRow contactDomain)
profileId <- insertedRowId db
DB.execute
db
@@ -488,15 +488,15 @@ type PreparedContactRow = (Maybe AConnectionRequestUri, Maybe AConnShortLink, Ma
type GroupDirectInvitationRow = (Maybe ConnReqInvitation, Maybe GroupId, Maybe GroupMemberId, Maybe Int64, BoolInt)
type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) :. BadgeRow :. ContactDomainRow
type ContactRow' = (ProfileId, ContactName, ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, BoolInt, ContactStatus) :. (Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe Preferences, Preferences, UTCTime, UTCTime, Maybe UTCTime) :. PreparedContactRow :. (Maybe Int64, Maybe GroupMemberId, BoolInt) :. GroupDirectInvitationRow :. (Maybe UIThemeEntityOverrides, BoolInt, Maybe CustomData, Maybe Int64) :. BadgeRow :. ContactDomainRow
type ContactRow = Only ContactId :. ContactRow'
type ContactDomainRow = (Maybe SimplexDomain, Maybe SimplexDomainProof, Maybe BoolInt)
toContact :: UTCTime -> StoreCxt -> User -> [ChatTagId] -> ContactRow :. MaybeConnectionRow -> Contact
toContact now cxt user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow :. domainRow) :. connRow) =
let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge now badgeRow, preferences, localAlias}
toContact now cxt user chatTags ((Only contactId :. (profileId, localDisplayName, displayName, fullName, shortDescr, description, image, contactLink, peerType, localAlias, BI contactUsed, contactStatus) :. (enableNtfs_, sendRcpts, BI favorite, preferences, userPreferences, createdAt, updatedAt, chatTs) :. preparedContactRow :. (contactRequestId, contactGroupMemberId, BI contactGrpInvSent) :. groupDirectInvRow :. (uiThemes, BI chatDeleted, customData, chatItemTTL) :. badgeRow :. domainRow) :. connRow) =
let profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge now badgeRow, preferences, localAlias}
activeConn = toMaybeConnection cxt connRow
chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite}
incognito = maybe False connIncognito activeConn
@@ -537,25 +537,25 @@ getProfileById db userId profileId = do
DB.query
db
[sql|
SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences, -- , ct.user_preferences
SELECT cp.contact_profile_id, cp.display_name, cp.full_name, cp.short_descr, cp.description, cp.image, cp.contact_link, cp.chat_peer_type, cp.local_alias, cp.preferences, -- , ct.user_preferences
cp.badge_proof, cp.badge_pres_header, cp.badge_expiry, cp.badge_type, cp.badge_verified, cp.badge_extra, cp.badge_master_key, cp.badge_signature, cp.badge_key_idx, cp.contact_domain, cp.contact_domain_proof, cp.contact_domain_verified
FROM contact_profiles cp
WHERE cp.user_id = ? AND cp.contact_profile_id = ?
|]
(userId, profileId)
type ContactRequestRow = (Int64, ContactName, AgentInvId, Maybe ContactId, Maybe GroupId, Maybe Int64) :. (Int64, ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias) :. (Maybe XContactId, PQSupport, Maybe SharedMsgId, Maybe SharedMsgId, Maybe Preferences, UTCTime, UTCTime, VersionChat, VersionChat) :. BadgeRow :. ContactDomainRow
type ContactRequestRow = (Int64, ContactName, AgentInvId, Maybe ContactId, Maybe GroupId, Maybe Int64) :. (Int64, ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias) :. (Maybe XContactId, PQSupport, Maybe SharedMsgId, Maybe SharedMsgId, Maybe Preferences, UTCTime, UTCTime, VersionChat, VersionChat) :. BadgeRow :. ContactDomainRow
toContactRequest :: UTCTime -> ContactRequestRow -> UserContactRequest
toContactRequest now ((contactRequestId, localDisplayName, agentInvitationId, contactId_, businessGroupId_, userContactLinkId_) :. (profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias) :. (xContactId, pqSupport, welcomeSharedMsgId, requestSharedMsgId, preferences, createdAt, updatedAt, minVer, maxVer) :. badgeRow :. domainRow) = do
let profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, preferences, localBadge = rowToBadge now badgeRow, localAlias}
toContactRequest now ((contactRequestId, localDisplayName, agentInvitationId, contactId_, businessGroupId_, userContactLinkId_) :. (profileId, displayName, fullName, shortDescr, description, image, contactLink, peerType, localAlias) :. (xContactId, pqSupport, welcomeSharedMsgId, requestSharedMsgId, preferences, createdAt, updatedAt, minVer, maxVer) :. badgeRow :. domainRow) = do
let profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, preferences, localBadge = rowToBadge now badgeRow, localAlias}
cReqChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer
in UserContactRequest {contactRequestId, agentInvitationId, contactId_, businessGroupId_, userContactLinkId_, cReqChatVRange, localDisplayName, profileId, profile, xContactId, pqSupport, welcomeSharedMsgId, requestSharedMsgId, createdAt, updatedAt}
userQuery :: Query
userQuery =
[sql|
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
SELECT u.user_id, u.agent_user_id, u.contact_id, ucp.contact_profile_id, u.active_user, u.active_order, u.local_display_name, ucp.full_name, ucp.short_descr, ucp.description, ucp.image, ucp.contact_link, ucp.chat_peer_type, ucp.preferences,
u.show_ntfs, u.send_rcpts_contacts, u.send_rcpts_small_groups, u.auto_accept_member_contacts, u.view_pwd_hash, u.view_pwd_salt, u.user_member_profile_updated_at, u.is_user_chat_relay, u.client_service, u.ui_themes,
ucp.badge_proof, ucp.badge_pres_header, ucp.badge_expiry, ucp.badge_type, ucp.badge_verified, ucp.badge_extra, ucp.badge_master_key, ucp.badge_signature, ucp.badge_key_idx, ucp.contact_domain, ucp.contact_domain_proof, ucp.contact_domain_verified
FROM users u
@@ -563,11 +563,11 @@ userQuery =
JOIN contact_profiles ucp ON ucp.contact_profile_id = uct.contact_profile_id
|]
toUser :: UTCTime -> (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, BoolInt, BoolInt, Maybe UIThemeEntityOverrides) :. BadgeRow :. ContactDomainRow -> User
toUser now ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, BI userChatRelay, BI clientService, uiThemes) :. badgeRow :. domainRow) =
toUser :: UTCTime -> (UserId, UserId, ContactId, ProfileId, BoolInt, Int64) :. (ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, Maybe Preferences) :. (BoolInt, BoolInt, BoolInt, BoolInt, Maybe B64UrlByteString, Maybe B64UrlByteString, Maybe UTCTime, BoolInt, BoolInt, Maybe UIThemeEntityOverrides) :. BadgeRow :. ContactDomainRow -> User
toUser now ((userId, auId, userContactId, profileId, BI activeUser, activeOrder) :. (displayName, fullName, shortDescr, description, image, contactLink, peerType, userPreferences) :. (BI showNtfs, BI sendRcptsContacts, BI sendRcptsSmallGroups, BI autoAcceptMemberContacts, viewPwdHash_, viewPwdSalt_, userMemberProfileUpdatedAt, BI userChatRelay, BI clientService, uiThemes) :. badgeRow :. domainRow) =
User {userId, agentUserId = AgentUserId auId, userContactId, localDisplayName = displayName, profile, activeUser, activeOrder, fullPreferences, showNtfs, sendRcptsContacts, sendRcptsSmallGroups, autoAcceptMemberContacts, viewPwdHash, userMemberProfileUpdatedAt, userChatRelay = BoolDef userChatRelay, clientService = BoolDef clientService, uiThemes}
where
profile = LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge now badgeRow, preferences = userPreferences, localAlias = ""}
profile = LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge now badgeRow, preferences = userPreferences, localAlias = ""}
fullPreferences = fullPreferences' userPreferences
viewPwdHash = UserPwdHash <$> viewPwdHash_ <*> viewPwdSalt_
@@ -689,7 +689,7 @@ type PublicGroupAccessRow = (Maybe Text, Maybe SimplexDomain, Maybe BoolInt, May
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) :. BadgeRow :. ContactDomainRow
type ProfileRow = (ProfileId, ContactName, Text, Maybe Text, Maybe Text, Maybe ImageData, Maybe ConnLinkContact, Maybe ChatPeerType, LocalAlias, Maybe Preferences) :. BadgeRow :. ContactDomainRow
toGroupInfo :: UTCTime -> StoreCxt -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo
toGroupInfo now cxt 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, rosterVersion, customData, chatItemTTL, membersRequireAttention, viaGroupLinkUri, groupDomainVerified) :. groupKeysRow :. userMemberRow) =
@@ -762,7 +762,7 @@ groupMemberQuery =
[sql|
SELECT
m.group_member_id, m.group_id, m.index_in_group, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction,
m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.short_descr, p.description, p.image, p.contact_link, p.chat_peer_type, p.local_alias, p.preferences,
p.badge_proof, p.badge_pres_header, p.badge_expiry, p.badge_type, p.badge_verified, p.badge_extra, p.badge_master_key, p.badge_signature, p.badge_key_idx, p.contact_domain, p.contact_domain_proof, p.contact_domain_verified,
m.created_at, m.updated_at,
m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, m.support_chat_last_msg_from_member_ts, m.member_pub_key, m.relay_link,
@@ -780,8 +780,8 @@ toContactMember now cxt User {userContactId} (memberRow :. connRow) =
(toGroupMember now userContactId memberRow) {activeConn = toMaybeConnection cxt connRow}
rowToLocalProfile :: UTCTime -> ProfileRow -> LocalProfile
rowToLocalProfile now ((profileId, displayName, fullName, shortDescr, image, contactLink, peerType, localAlias, preferences) :. badgeRow :. domainRow) =
LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge now badgeRow, localAlias, preferences}
rowToLocalProfile now ((profileId, displayName, fullName, shortDescr, description, image, contactLink, peerType, localAlias, preferences) :. badgeRow :. domainRow) =
LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, contactDomain = rowToContactDomain domainRow, contactDomainVerified = rowToDomainVerified domainRow, peerType, localBadge = rowToBadge now badgeRow, localAlias, preferences}
toBusinessChatInfo :: Maybe SimplexDomainClaim -> BusinessChatInfoRow -> Maybe BusinessChatInfo
toBusinessChatInfo businessDomain (Just chatType, Just businessId, Just customerId) = Just BusinessChatInfo {chatType, businessId, customerId, businessDomain}
@@ -807,7 +807,7 @@ groupInfoQueryFields =
-- GroupMember - membership
mu.group_member_id, mu.group_id, mu.index_in_group, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category,
mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id,
pu.display_name, pu.full_name, pu.short_descr, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences,
pu.display_name, pu.full_name, pu.short_descr, pu.description, pu.image, pu.contact_link, pu.chat_peer_type, pu.local_alias, pu.preferences,
pu.badge_proof, pu.badge_pres_header, pu.badge_expiry, pu.badge_type, pu.badge_verified, pu.badge_extra, pu.badge_master_key, pu.badge_signature, pu.badge_key_idx, pu.contact_domain, pu.contact_domain_proof, pu.contact_domain_verified,
mu.created_at, mu.updated_at,
mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, mu.support_chat_last_msg_from_member_ts, mu.member_pub_key, mu.relay_link
+10 -8
View File
@@ -694,6 +694,7 @@ data Profile = Profile
{ displayName :: ContactName,
fullName :: Text,
shortDescr :: Maybe Text, -- short description limited to 160 characters
description :: Maybe Text, -- long description (businesses/bots); redacted per group policy in member profiles
image :: Maybe ImageData,
contactLink :: Maybe ConnLinkContact,
preferences :: Maybe Preferences,
@@ -732,14 +733,14 @@ instance TextEncoding ChatPeerType where
profileFromName :: ContactName -> Profile
profileFromName displayName =
Profile {displayName, fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, preferences = Nothing, peerType = Nothing, badge = Nothing, contactDomain = Nothing}
Profile {displayName, fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, preferences = Nothing, peerType = Nothing, badge = Nothing, contactDomain = Nothing}
-- check if profiles match ignoring preferences
profilesMatch :: LocalProfile -> LocalProfile -> Bool
profilesMatch
LocalProfile {displayName = n1, fullName = fn1, image = i1, shortDescr = d1}
LocalProfile {displayName = n2, fullName = fn2, image = i2, shortDescr = d2} =
n1 == n2 && fn1 == fn2 && i1 == i2 && d1 == d2
LocalProfile {displayName = n1, fullName = fn1, image = i1, shortDescr = d1, description = desc1}
LocalProfile {displayName = n2, fullName = fn2, image = i2, shortDescr = d2, description = desc2} =
n1 == n2 && fn1 == fn2 && i1 == i2 && d1 == d2 && desc1 == desc2
-- equal for profile-update detection: badge proofs are re-generated for every presentation,
-- so compare badges by disclosed info (not proof bytes) - a re-presentation of the same badge is a no-op
@@ -778,6 +779,7 @@ data LocalProfile = LocalProfile
displayName :: ContactName,
fullName :: Text,
shortDescr :: Maybe Text,
description :: Maybe Text,
image :: Maybe ImageData,
contactLink :: Maybe ConnLinkContact,
preferences :: Maybe Preferences,
@@ -793,15 +795,15 @@ localProfileId :: LocalProfile -> ProfileId
localProfileId LocalProfile {profileId} = profileId
toLocalProfile :: ProfileId -> Profile -> LocalAlias -> UTCTime -> Maybe Bool -> Maybe Bool -> LocalProfile
toLocalProfile profileId Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, badge, contactDomain} localAlias now badgeVerified contactDomainVerified =
LocalProfile {profileId, displayName, fullName, shortDescr, image, contactLink, preferences, peerType, localBadge, localAlias, contactDomain, contactDomainVerified}
toLocalProfile profileId Profile {displayName, fullName, shortDescr, description, image, contactLink, preferences, peerType, badge, contactDomain} localAlias now badgeVerified contactDomainVerified =
LocalProfile {profileId, displayName, fullName, shortDescr, description, image, contactLink, preferences, peerType, localBadge, localAlias, contactDomain, contactDomainVerified}
where
localBadge = (\b@(BadgeProof _ _ _ info) -> PeerBadge b (mkBadgeStatus now badgeVerified info)) <$> badge
fromLocalProfile :: LocalProfile -> Profile
fromLocalProfile LocalProfile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, localBadge, contactDomain} =
fromLocalProfile LocalProfile {displayName, fullName, shortDescr, description, image, contactLink, preferences, peerType, localBadge, contactDomain} =
-- the name proof is re-signed on each send
Profile {displayName, fullName, shortDescr, image, contactLink, preferences, peerType, badge = localBadge >>= wireBadge, contactDomain = (\d -> d {proof = Nothing} :: SimplexDomainClaim) <$> contactDomain}
Profile {displayName, fullName, shortDescr, description, image, contactLink, preferences, peerType, badge = localBadge >>= wireBadge, contactDomain = (\d -> d {proof = Nothing} :: SimplexDomainClaim) <$> contactDomain}
where
wireBadge :: LocalBadge -> Maybe BadgeProof
wireBadge = \case
+4 -2
View File
@@ -1815,9 +1815,10 @@ viewContactBadge = maybe [] $ \lb ->
in [plain (textEncode badgeType <> " badge - " <> st), plain expiry]
viewContactInfo :: Contact -> Maybe ConnectionStats -> Maybe Profile -> [StyledString]
viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink, localBadge, contactDomain, contactDomainVerified}, activeConn, uiThemes, customData} stats incognitoProfile =
viewContactInfo ct@Contact {contactId, profile = LocalProfile {localAlias, contactLink, localBadge, contactDomain, contactDomainVerified, description}, activeConn, uiThemes, customData} stats incognitoProfile =
["contact ID: " <> sShow contactId]
<> viewContactBadge localBadge
<> maybe [] ((bold' "description:" :) . map plain . T.lines) description
<> maybe [] viewConnectionStats stats
<> maybe [] (\l -> ["contact address: " <> plain (strEncode (simplexChatContact' l))]) contactLink
<> simplexDomainLine NTContact contactDomain contactDomainVerified
@@ -1856,11 +1857,12 @@ viewCustomData :: Maybe CustomData -> [StyledString]
viewCustomData = maybe [] (\(CustomData v) -> ["custom data: " <> viewJSON (J.Object v)])
viewGroupMemberInfo :: GroupInfo -> GroupMember -> Maybe ConnectionStats -> [StyledString]
viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias, contactLink, localBadge}, activeConn} stats =
viewGroupMemberInfo GroupInfo {groupId} m@GroupMember {groupMemberId, memberProfile = LocalProfile {localAlias, contactLink, localBadge, description}, activeConn} stats =
[ "group ID: " <> sShow groupId,
"member ID: " <> sShow groupMemberId
]
<> viewContactBadge localBadge
<> maybe [] ((bold' "description:" :) . map plain . T.lines) description
<> maybe ["member not connected"] viewConnectionStats stats
<> maybe [] (\l -> ["contact address: " <> (plain . strEncode) (simplexChatContact' l)]) contactLink
<> ["alias: " <> plain localAlias | localAlias /= ""]
+1 -1
View File
@@ -33,7 +33,7 @@ withBroadcastBot opts test =
bot = simplexChatCore testCfg (mkChatOpts opts) $ broadcastBot opts
broadcastBotProfile :: Profile
broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing}
broadcastBotProfile = Profile {displayName = "broadcast_bot", fullName = "Broadcast Bot", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing}
mkBotOpts :: TestParams -> [KnownContact] -> BroadcastBotOpts
mkBotOpts ps publishers =
+1 -1
View File
@@ -109,7 +109,7 @@ directoryNameTests = do
it "should mark an inconsistent SimpleX name as not verified" testDirectoryChannelNameNotVerified
directoryProfile :: Profile
directoryProfile = Profile {displayName = "SimpleX Directory", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing}
directoryProfile = Profile {displayName = "SimpleX Directory", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Just CPTBot, preferences = Nothing, badge = Nothing, contactDomain = Nothing}
mkDirectoryOpts :: TestParams -> [KnownContact] -> Maybe KnownGroup -> Maybe FilePath -> DirectoryOpts
mkDirectoryOpts TestParams {tmpPath = ps} superUsers ownersGroup webFolder =
+66 -1
View File
@@ -46,6 +46,8 @@ chatProfileTests :: SpecWith TestParams
chatProfileTests = do
describe "user profiles" $ do
it "update user profile and notify contacts" testUpdateProfile
it "profile description round-trips and shows in contact info" testProfileDescriptionShown
it "member profile description is redacted for members without a direct contact" testMemberDescriptionRedacted
it "update user profile with image" testUpdateProfileImage
it "use multiword profile names" testMultiWordProfileNames
it "present supporter badge to contacts" testUserBadgeBroadcast
@@ -199,6 +201,69 @@ testUpdateProfile =
bob <## "use @cat <message> to send messages"
]
-- Profile.description survives the connect-time round-trip and is shown in the contact /i view.
testProfileDescriptionShown :: HasCallStack => TestParams -> IO ()
testProfileDescriptionShown =
testChat2 aliceProfile bobWithDescr $
\alice bob -> do
connectUsers alice bob
alice ##> "/i @bob"
alice <## "contact ID: 2"
alice <## "description:"
alice <## "check https://simplex.chat out"
alice <##. "receiving messages via"
alice <##. "sending messages via"
alice <## "you've shared main profile with this contact"
alice <## "connection not verified, use /code command to see security code"
alice <## "quantum resistant end-to-end encryption"
alice <##. "peer chat protocol version range"
where
bobWithDescr = bobProfile {description = Just "check https://simplex.chat out"}
-- for a member without a direct contact, the description is redacted per the group's link/name policy
testMemberDescriptionRedacted :: HasCallStack => TestParams -> IO ()
testMemberDescriptionRedacted =
testChat3 aliceProfile bobProfile cathWithDescr $
\alice bob cath -> do
connectUsers alice bob
connectUsers alice cath
alice ##> "/g team"
alice <## "group #team is created"
alice <## "to add members use /a team <name> or /create link #team"
-- prohibit direct messages (and thus simplex links) before members join
alice ##> "/set direct #team off"
alice <## "updated group preferences:"
alice <## "Direct messages: off"
addMember "team" alice bob GRAdmin
bob ##> "/j team"
concurrently_
(alice <## "#team: bob joined the group")
(bob <## "#team: you joined the group")
-- cath joins and is introduced to bob with a redacted profile (link stripped)
addMember "team" alice cath GRAdmin
cath ##> "/j team"
concurrentlyN_
[ alice <## "#team: cath joined the group",
do
cath <## "#team: you joined the group"
cath <## "#team: member bob (Bob) is connected",
do
bob <## "#team: alice added cath (Catherine) to the group (connecting...)"
bob <## "#team: new member cath is connected"
]
-- bob has no direct contact to cath, so his stored member profile has the link stripped
bob ##> "/i #team cath"
bob <## "group ID: 1"
bob <##. "member ID:"
bob <## "description:"
bob <## "check out"
bob <##. "receiving messages via"
bob <##. "sending messages via"
bob <## "connection not verified, use /code command to see security code"
bob <##. "peer chat protocol version range"
where
cathWithDescr = cathProfile {description = Just "check https://simplex.chat out"}
-- the test issuer key under index 1 in the test config
testBadgeKeys :: BBSPublicKey -> M.Map Int BBSPublicKey
testBadgeKeys = M.singleton 1
@@ -497,7 +562,7 @@ testMultiWordProfileNames =
aliceProfile' = baseProfile {displayName = "Alice Jones"}
bobProfile' = baseProfile {displayName = "Bob James"}
cathProfile' = baseProfile {displayName = "Cath Johnson"}
baseProfile = Profile {displayName = "", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs, badge = Nothing, contactDomain = Nothing}
baseProfile = Profile {displayName = "", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs, badge = Nothing, contactDomain = Nothing}
testUserContactLink :: HasCallStack => TestParams -> IO ()
testUserContactLink =
+1 -1
View File
@@ -88,7 +88,7 @@ serviceProfile :: Profile
serviceProfile = mkProfile "service_user" "Service user" Nothing
mkProfile :: T.Text -> T.Text -> Maybe ImageData -> Profile
mkProfile displayName descr image = Profile {displayName, fullName = "", shortDescr = Just descr, image, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs, badge = Nothing, contactDomain = Nothing}
mkProfile displayName descr image = Profile {displayName, fullName = "", shortDescr = Just descr, description = Nothing, image, contactLink = Nothing, peerType = Nothing, preferences = defaultPrefs, badge = Nothing, contactDomain = Nothing}
it :: HasCallStack => String -> (ps -> Expectation) -> SpecWith (Arg (ps -> Expectation))
it name test =
+2 -2
View File
@@ -108,7 +108,7 @@ testGroupPreferences :: Maybe GroupPreferences
testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn, role = Nothing}, files = Nothing, fullDelete = Nothing, simplexLinks = Nothing, history = Nothing, reports = Nothing, support = Nothing, sessions = Nothing, comments = Nothing, signMessages = Nothing, commands = Nothing}
testProfile :: Profile
testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, preferences = testChatPreferences, badge = Nothing, contactDomain = Nothing}
testProfile = Profile {displayName = "alice", fullName = "Alice", shortDescr = Nothing, description = Nothing, image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), peerType = Nothing, contactLink = Nothing, preferences = testChatPreferences, badge = Nothing, contactDomain = Nothing}
testGroupProfile :: GroupProfile
testGroupProfile = GroupProfile {displayName = "team", fullName = "Team", description = Nothing, shortDescr = Nothing, image = Nothing, publicGroup = Nothing, groupPreferences = testGroupPreferences, memberAdmission = Nothing}
@@ -241,7 +241,7 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do
#==# XInfo testProfile
it "x.info with empty full name" $
"{\"v\":\"1\",\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
#==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = testChatPreferences, badge = Nothing, contactDomain = Nothing}
#==# XInfo Profile {displayName = "alice", fullName = "", shortDescr = Nothing, description = Nothing, image = Nothing, contactLink = Nothing, peerType = Nothing, preferences = testChatPreferences, badge = Nothing, contactDomain = Nothing}
it "x.contact with xContactId" $
"{\"v\":\"1\",\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}"
#==# XContact testProfile (Just $ XContactId "\1\2\3\4") Nothing Nothing