From 9276ff32eec47245e3ba400fbde584b4dfe8fad4 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:20:11 +0400 Subject: [PATCH] wip --- apps/ios/Shared/Views/Chat/ChatView.swift | 20 ++- plans/2026-04-29-channel-reaction-privacy.md | 48 ++++-- simplex-chat.cabal | 2 + src/Simplex/Chat/Delivery.hs | 6 + src/Simplex/Chat/Library/Commands.hs | 12 +- src/Simplex/Chat/Library/Subscriber.hs | 148 +++++++++++++----- src/Simplex/Chat/Store/Delivery.hs | 35 +++-- src/Simplex/Chat/Store/Messages.hs | 26 +++ src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Migrations/M20260430_subscriber_body.hs | 19 +++ .../Store/Postgres/Migrations/chat_schema.sql | 1 + src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20260430_subscriber_body.hs | 18 +++ .../Store/SQLite/Migrations/chat_schema.sql | 1 + 14 files changed, 256 insertions(+), 88 deletions(-) create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260430_subscriber_body.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260430_subscriber_body.hs diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index e165c01710..89060f624c 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -2247,14 +2247,18 @@ struct ChatView: View { } switch chat.chatInfo { case let .group(groupInfo, _): - v.contextMenu { - ReactionContextMenu( - groupInfo: groupInfo, - itemId: ci.id, - reactionCount: r, - selectedMember: $selectedMember, - profileRadius: profileRadius - ) + if groupInfo.useRelays && !groupInfo.isOwner { + v + } else { + v.contextMenu { + ReactionContextMenu( + groupInfo: groupInfo, + itemId: ci.id, + reactionCount: r, + selectedMember: $selectedMember, + profileRadius: profileRadius + ) + } } case let .direct(contact): v.contextMenu { diff --git a/plans/2026-04-29-channel-reaction-privacy.md b/plans/2026-04-29-channel-reaction-privacy.md index ee85b86a88..c4d68b352a 100644 --- a/plans/2026-04-29-channel-reaction-privacy.md +++ b/plans/2026-04-29-channel-reaction-privacy.md @@ -124,6 +124,8 @@ Add `subscriber_body` to the INSERT. Existing call sites (2 in Subscriber.hs tas Update `MessageDeliveryJobRow`, `getNextDeliveryJob` query, and `toDeliveryJob` to read `subscriber_body` column into the new `subscriberBody_` field. +In `getNextDeliveryTasks`, add sender filtering for DJReaction tasks on relay groups (via `isSenderFiltered` helper). This ensures `singleSenderGMId_` is always Just for DJReaction jobs, preventing subscribers from receiving their own reaction back via FwdChannel in multi-sender batches. + ### 3. DB migration ```sql @@ -364,7 +366,7 @@ Reaction pills (emoji + count) remain visible. Only the long-press context menu | File | Change | |------|--------| | `src/Simplex/Chat/Delivery.hs` | Add `DJReaction` to `DeliveryJobSpec`/tag; add `subscriberBody_` to `MessageDeliveryJob` | -| `src/Simplex/Chat/Store/Delivery.hs` | `jobScopeRow_`/`toJobScope_` for new tag; `createMsgDeliveryJob` new parameter; `getNextDeliveryJob` reads new column | +| `src/Simplex/Chat/Store/Delivery.hs` | `jobScopeRow_`/`toJobScope_` for new tag; `createMsgDeliveryJob` new parameter; `getNextDeliveryJob` reads new column; `getNextDeliveryTasks` sender-filters DJReaction | | `src/Simplex/Chat/Library/Subscriber.hs` | `groupMsgReaction` accepts `Maybe GroupMember`, adds channel reaction path, returns `DJReaction` for channels; task/job workers handle `DJReaction`; `processForwardedMsg` passes `author_` directly | | `src/Simplex/Chat/Store/Messages.hs` | New `setGroupReactionNoMember` | | `src/Simplex/Chat/Library/Commands.hs` | `APIGetReactionMembers` restricted for non-owner channel members | @@ -375,40 +377,52 @@ Reaction pills (emoji + count) remain visible. Only the long-press context menu ### Pass 1 -1. **Owner duplicate delivery**: Owner is a member. In the cursor loop, they're partitioned into the `owners` group and receive `body` (FwdMember). They do NOT receive `subscriberBody`. No duplicate reactions. +1. **Owner duplicate delivery**: Owner partitioned into `owners` group in `sendBodyToMembersReaction` via `memberRole' m >= GROwner`. Receives `body` (FwdMember) only. No subscriber body sent to owners. -2. **Subscriber's own reaction**: The reacting subscriber is excluded from delivery via `singleSenderGMId_` in `getGroupMembersByCursor` (`AND group_member_id IS DISTINCT FROM ?`). No self-delivery, no double-counting. +2. **Subscriber's own reaction excluded**: `isSenderFiltered` in `getNextDeliveryTasks` ensures DJReaction tasks are always sender-filtered (even for relay groups). All tasks in a DJReaction batch are from the same sender, so `singleSenderGMId_` is always Just. `getGroupMembersByCursor` excludes the sender via `AND group_member_id IS DISTINCT FROM ?`. -3. **Owner's reaction identity hidden from subscribers**: When an owner reacts, the relay processes it and returns `DJReaction`. The subscriber body uses `FwdChannel`, hiding the owner's identity from subscribers. Owners see each other's identities via `body`. Correct. +3. **Multi-sender batch dedup** (found and fixed during review): Without sender filtering, if multiple senders' DJReaction tasks were batched, `singleSenderGMId_` would be Nothing and senders would receive their own reactions back as FwdChannel, causing duplicate NULL-member reaction rows. Fixed by adding `isSenderFiltered` to always use sender-filtered query for DJReaction. Tasks from other senders remain DTSNew and are processed in subsequent iterations. -4. **Member support scope reactions**: The member support scope branch in `groupMsgReaction` (line 1929–1934) returns `DJSMemberSupport`, an entirely different delivery scope processed by a different job worker path. Unaffected. +4. **Owner's reaction identity hidden from subscribers**: Owner reacts → relay returns DJReaction → subscriber body uses FwdChannel → subscribers see only emoji + count, no member identity. -5. **Non-channel groups**: `groupMsgReaction` returns `DJReaction` only when `useRelays' g`. Regular groups continue using `DJDeliveryJob`. Unaffected. +5. **Non-channel groups unaffected**: `DJReaction` only returned when `useRelays' g`. Regular groups use `DJDeliveryJob`. `isSenderFiltered` returns False for non-DJReaction scopes. -6. **Batching**: `getNextDeliveryTasks` filters by `jobScopeRow_`. `DJReaction` tasks have tag `"reaction"` — they batch with each other, NOT with `DJDeliveryJob` tasks. Multiple rapid reactions are correctly batched together, and both owner and subscriber bodies contain all batched elements. +6. **Member support scope unaffected**: Returns `DJSMemberSupport`, different delivery scope and worker. Never interacts with DJReaction. -7. **`getGroupCIReactions` counts NULL-member reactions**: The aggregation query groups by reaction and counts `chat_item_reaction_id` with no filter on `group_member_id`. NULL-member rows are counted. `userReacted = MAX(reaction_sent)` — for received anonymous reactions `reaction_sent = 0`, for the user's own reaction `reaction_sent = 1`. Correct. +7. **Batching**: DJReaction tasks batch same-scope AND same-sender. Tag `"reaction"` separates from `DJDeliveryJob`. Both owner and subscriber bodies encode the same set of tasks. -8. **`getReactionMembers` on owner's device**: On the owner's device, reactions arrive via `FwdMember` and are stored with real `group_member_id` values. `getReactionMembers` fetches by `group_member_id` and joins `group_members` — works as before. On the subscriber's device, `APIGetReactionMembers` returns empty (API restriction), so NULL-member reactions are never queried for member details. +8. **`getGroupCIReactions` counts NULL-member reactions**: Aggregation groups by reaction, counts all rows. `userReacted = MAX(reaction_sent)` — 0 for anonymous, 1 for user's own. -9. **Reaction removal (add=False)**: Relay processes removal, returns `DJReaction`. Task/job workers encode both bodies. Owner receives `FwdMember` removal — `setGroupReaction` deletes the specific member's reaction row. Subscriber receives `FwdChannel` removal — `groupMsgReaction` `Nothing` branch calls `setGroupReactionNoMember`, which deletes one NULL-member row via `LIMIT 1`. Correct. +9. **API restriction consistent with UI**: Both use `< GROwner` threshold. `APIGetReactionMembers` returns `[]` for non-owner channel subscribers. iOS suppresses context menu. -10. **`saveGroupFwdRcvMsg` with `Nothing` author**: Already works — `refAuthorId = groupMemberId' <$> refAuthorMember_` handles `Nothing` (Internal.hs:2283). Dedup via `sharedMsgId_` is unaffected. +10. **Reaction removal (add=False)**: `setGroupReactionNoMember` DELETE uses LIMIT 1 subquery. Finds one NULL-member row or no-ops if none exists. -11. **`createMsgDeliveryJob` call sites**: Three existing call sites (Subscriber.hs:3531, 3548; Commands.hs:2927) need `Nothing` as the new subscriber body parameter. Mechanical change. +11. **`processForwardedMsg` passes `author_` directly**: FwdMember → Just author → `groupMsgReaction (Just m)`. FwdChannel → Nothing → `groupMsgReaction Nothing`. Matches sibling handlers. + +12. **Migration**: `subscriber_body` column nullable. Existing rows get NULL. `toDeliveryJob` reads as `Maybe (Binary ByteString)`. No issues found. ### Pass 2 -12. **Subscriber body encoding size**: `FwdChannel` encodes as `"C"` (1 byte) vs `FwdMember` which is `"M" + memberId + memberName` (variable, larger). If the owner body fits within `maxEncodedMsgLength`, the subscriber body will also fit (same content, smaller wrapper). No overflow risk. +13. **`isSenderFiltered` correctness**: Only matches `DJSGroup {jobSpec = DJReaction}`. DJDeliveryJob and DJRelayRemoved on relay groups continue without sender filter. DJRelayRemoved doesn't use `getNextDeliveryTasks` (single-task processing). Only DJDeliveryJob and DJReaction go through the plural version. Behavior preserved for all existing paths. -13. **`catchCINotFound` in `groupMsgReaction` `Nothing` branch**: When the chat item doesn't exist yet, the fallback stores the reaction with NULL member via `setGroupReactionNoMember`. When the chat item eventually arrives, `getGroupCIReactions` picks up all stored reactions including NULL-member ones. Counts are correct. +14. **Subscriber body encoding includes signatures**: For signed messages, `encodeFwdElement` includes signature bytes. On receiving side, FwdChannel path discards them with `VMUnsigned chatMsg`. Extra bytes but functionally correct. No information leak. -14. **`CIChannelRcv` direction in reaction UI event**: `CIReaction` uses `chatDir :: CIDirection c d`. `CIChannelRcv` is a valid `CIDirection 'CTGroup 'MDRcv`. The iOS event handler (`.chatItemReaction`) just calls `m.updateChatItem(r.chatInfo, r.chatReaction.chatItem)` — it updates the chat item's reaction counts regardless of direction. No UI issue. +15. **`partition` threshold `>= GROwner`**: Only GROwner gets owner body. GRAdmin, GRModerator, GRMember, GRObserver get subscriber body. Correct for channel privacy model. -15. **Old client compatibility**: Old clients that receive `FwdChannel` + `XMsgReact` parse `FwdChannel` correctly (it's an existing encoding). They still use `withAuthor XMsgReact_` → error logged, reaction silently dropped. Acceptable degradation — no crash, reaction count is marginally lower until client update. +16. **Subscriber body encoding size**: FwdChannel encodes smaller than FwdMember. If owner body fits in `maxEncodedMsgLength`, subscriber body fits too. -16. **`subscriberBody_` for non-`DJReaction` jobs**: The field is `Maybe ByteString`, defaults to `Nothing` for all other job types. The `fromMaybe body subscriberBody_` fallback in the job worker ensures non-owners get `body` if no subscriber body exists. Safe. +17. **`catchCINotFound` fallback in `Nothing` branch**: Stores reaction without UI event when chat item doesn't exist. Matches `Just m` branch fallback behavior. + +18. **`CIChannelRcv` direction in reaction UI event**: Valid `CIDirection 'CTGroup 'MDRcv`. iOS event handler updates reaction counts regardless of direction. + +19. **Old client compatibility**: FwdChannel is existing encoding. Old clients hit `withAuthor XMsgReact_` → error logged, reaction dropped. No crash. Acceptable degradation. + +20. **`subscriberBody_` for non-`DJReaction` jobs**: `Maybe ByteString`, defaults to `Nothing`. `fromMaybe body subscriberBody_` fallback safe. + +21. **Column mapping**: SELECT lists 9 columns matching `MessageDeliveryJobRow` type. `subscriber_body` at position 8 maps to `Maybe (Binary ByteString)`. + +22. **`sentAsGroup = False` for DJReaction tasks**: Task worker reads `showGroupAsSender = False` → `fwdSender = FwdMember`. Owner body has member identity. Subscriber body explicitly overrides with FwdChannel. No issues found. Two consecutive clean passes. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 70809d41ae..7d14ea788d 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -130,6 +130,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260122_has_link Simplex.Chat.Store.Postgres.Migrations.M20260222_chat_relays Simplex.Chat.Store.Postgres.Migrations.M20260403_item_viewed + Simplex.Chat.Store.Postgres.Migrations.M20260430_subscriber_body else exposed-modules: Simplex.Chat.Archive @@ -282,6 +283,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260122_has_link Simplex.Chat.Store.SQLite.Migrations.M20260222_chat_relays Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed + Simplex.Chat.Store.SQLite.Migrations.M20260430_subscriber_body other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Delivery.hs b/src/Simplex/Chat/Delivery.hs index 822ee5efb9..8cc4c02dc9 100644 --- a/src/Simplex/Chat/Delivery.hs +++ b/src/Simplex/Chat/Delivery.hs @@ -59,11 +59,13 @@ data DeliveryJobScope data DeliveryJobSpec = DJDeliveryJob {includePending :: Bool} + | DJReaction | DJRelayRemoved deriving (Show) data DeliveryJobSpecTag = DJSTDeliveryJob + | DJSTReaction | DJSTRelayRemoved deriving (Show) @@ -74,10 +76,12 @@ instance ToField DeliveryJobSpecTag where toField = toField . textEncode instance TextEncoding DeliveryJobSpecTag where textDecode = \case "delivery_job" -> Just DJSTDeliveryJob + "reaction" -> Just DJSTReaction "relay_removed" -> Just DJSTRelayRemoved _ -> Nothing textEncode = \case DJSTDeliveryJob -> "delivery_job" + DJSTReaction -> "reaction" DJSTRelayRemoved -> "relay_removed" toWorkerScope :: DeliveryJobScope -> DeliveryWorkerScope @@ -101,6 +105,7 @@ jobScopeImpliedSpec = \case jobSpecImpliedPending :: DeliveryJobSpec -> Bool jobSpecImpliedPending = \case DJDeliveryJob {includePending} -> includePending + DJReaction -> False DJRelayRemoved -> True infoToDeliveryContext :: GroupInfo -> Maybe GroupChatScopeInfo -> ShowGroupAsSender -> DeliveryTaskContext @@ -163,6 +168,7 @@ data MessageDeliveryJob = MessageDeliveryJob jobScope :: DeliveryJobScope, singleSenderGMId_ :: Maybe GroupMemberId, -- Just for single-sender deliveries, Nothing for multi-sender deliveries body :: ByteString, + subscriberBody_ :: Maybe ByteString, cursorGMId_ :: Maybe GroupMemberId } deriving (Show) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 31e6533ad3..142ea7fad7 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -882,9 +882,13 @@ processChatCommand vr nm = \case when (add && length rs >= maxMsgReactions) $ throwCmdError "too many reactions" APIGetReactionMembers userId groupId itemId reaction -> withUserId userId $ \user -> do - memberReactions <- withStore $ \db -> do - CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}} <- getGroupChatItem db user groupId itemId - liftIO $ getReactionMembers db vr user groupId itemSharedMId reaction + gInfo <- withStore $ \db -> getGroupInfo db vr user groupId + memberReactions <- + if useRelays' gInfo && memberRole' (membership gInfo) < GROwner + then pure [] + else withStore $ \db -> do + CChatItem _ ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}} <- getGroupChatItem db user groupId itemId + liftIO $ getReactionMembers db vr user groupId itemSharedMId reaction pure $ CRReactionMembers user memberReactions -- TODO [knocking] forward from scope? APIPlanForwardChatItems (ChatRef fromCType fromChatId _scope) itemIds -> withUser $ \user -> case fromCType of @@ -2924,7 +2928,7 @@ processChatCommand vr nm = \case withFastStore' $ \db -> do deleteGroupDeliveryTasks db gInfo deleteGroupDeliveryJobs db gInfo - createMsgDeliveryJob db gInfo (DJSGroup {jobSpec = DJRelayRemoved}) Nothing body + createMsgDeliveryJob db gInfo (DJSGroup {jobSpec = DJRelayRemoved}) Nothing body Nothing lift . void $ getDeliveryJobWorker True (groupId, DWSGroup) pure msg leaveGroupSendMsg user gInfo = do diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 019d1e0fd7..56d6e51de4 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -28,7 +28,7 @@ import Data.Either (lefts, partitionEithers, rights) import Data.Foldable (foldr', foldrM) import Data.Functor (($>)) import Data.Int (Int64) -import Data.List (find) +import Data.List (find, partition) import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -995,7 +995,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = memberCanSend (Just m'') msgScope $ groupMessageUpdate gInfo' (Just m'') sharedMsgId mContent mentions msgScope msg brokerTs ttl live asGroup_ XMsgDel sharedMsgId memberId_ scope_ -> groupMessageDelete gInfo' (Just m'') sharedMsgId memberId_ scope_ msg brokerTs - XMsgReact sharedMsgId memberId scope_ reaction add -> groupMsgReaction gInfo' m'' sharedMsgId memberId scope_ reaction add msg brokerTs + XMsgReact sharedMsgId memberId scope_ reaction add -> groupMsgReaction gInfo' (Just m'') sharedMsgId memberId scope_ reaction add msg brokerTs -- TODO discontinue XFile XFile fInv -> Nothing <$ processGroupFileInvitation' gInfo' m'' fInv msg brokerTs XFileCancel sharedMsgId -> xFileCancelGroup gInfo' (Just m'') sharedMsgId @@ -1919,27 +1919,48 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else pure Nothing mapM_ toView cEvt_ - groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM (Maybe DeliveryTaskContext) - groupMsgReaction g m sharedMsgId itemMemberId scope_ reaction add RcvMessage {msgId} brokerTs - | groupFeatureAllowed SGFReactions g = do - rs <- withStore' $ \db -> getGroupReactions db g m itemMemberId sharedMsgId False - if reactionAllowed add reaction rs - then - updateChatItemReaction `catchCINotFound` \_ -> case scope_ of - Just (MSMember scopeMemberId) - | memberRole' m >= GRModerator || scopeMemberId == memberId' m -> do - djScope <- withStore $ \db -> do - liftIO $ setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs - Just . DJSMemberSupport <$> getScopeMemberIdViaMemberId db user g m scopeMemberId - pure $ fmap (\js -> DeliveryTaskContext js False) djScope - | otherwise -> pure Nothing - Nothing -> do - withStore' $ \db -> setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs - pure $ Just $ DeliveryTaskContext (DJSGroup {jobSpec = DJDeliveryJob {includePending = False}}) False - else pure Nothing + groupMsgReaction :: GroupInfo -> Maybe GroupMember -> SharedMsgId -> Maybe MemberId -> Maybe MsgScope -> MsgReaction -> Bool -> RcvMessage -> UTCTime -> CM (Maybe DeliveryTaskContext) + groupMsgReaction g m_ sharedMsgId itemMemberId scope_ reaction add RcvMessage {msgId} brokerTs + | groupFeatureAllowed SGFReactions g = case m_ of + Nothing -> + updateChannelReaction `catchCINotFound` \_ -> + withStore' (\db -> setGroupReactionNoMember db g itemMemberId sharedMsgId reaction add msgId brokerTs) + $> Nothing + Just m -> do + rs <- withStore' $ \db -> getGroupReactions db g m itemMemberId sharedMsgId False + if reactionAllowed add reaction rs + then + updateChatItemReaction m `catchCINotFound` \_ -> case scope_ of + Just (MSMember scopeMemberId) + | memberRole' m >= GRModerator || scopeMemberId == memberId' m -> do + djScope <- withStore $ \db -> do + liftIO $ setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs + Just . DJSMemberSupport <$> getScopeMemberIdViaMemberId db user g m scopeMemberId + pure $ fmap (\js -> DeliveryTaskContext js False) djScope + | otherwise -> pure Nothing + Nothing -> do + withStore' $ \db -> setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs + let jobSpec = if useRelays' g then DJReaction else DJDeliveryJob {includePending = False} + pure $ Just $ DeliveryTaskContext (DJSGroup {jobSpec}) False + else pure Nothing | otherwise = pure Nothing where - updateChatItemReaction = do + updateChannelReaction = do + (CChatItem md ci, scopeInfo) <- withStore $ \db -> do + cci <- case itemMemberId of + Just itemMemberId' -> getGroupMemberCIBySharedMsgId db user g itemMemberId' sharedMsgId + Nothing -> getGroupChatItemBySharedMsgId db user g Nothing sharedMsgId + scopeInfo <- getGroupChatScopeInfoForItem db vr user g (cChatItemId cci) + pure (cci, scopeInfo) + when (ciReactionAllowed ci) $ do + reactions <- withStore' $ \db -> do + setGroupReactionNoMember db g itemMemberId sharedMsgId reaction add msgId brokerTs + getGroupCIReactions db g itemMemberId sharedMsgId + let ci' = CChatItem md ci {reactions} + r = ACIReaction SCTGroup SMDRcv (GroupChat g scopeInfo) $ CIReaction CIChannelRcv ci' brokerTs reaction + toView $ CEvtChatItemReaction user add r + pure Nothing + updateChatItemReaction m = do (CChatItem md ci, scopeInfo) <- withStore $ \db -> do cci <- case itemMemberId of Just itemMemberId' -> getGroupMemberCIBySharedMsgId db user g itemMemberId' sharedMsgId @@ -1954,7 +1975,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let ci' = CChatItem md ci {reactions} r = ACIReaction SCTGroup SMDRcv (GroupChat g scopeInfo) $ CIReaction (CIGroupRcv m) ci' brokerTs reaction toView $ CEvtChatItemReaction user add r - pure $ Just $ infoToDeliveryContext g scopeInfo False + pure $ Just $ case scopeInfo of + Nothing | useRelays' g -> DeliveryTaskContext (DJSGroup {jobSpec = DJReaction}) False + _ -> infoToDeliveryContext g scopeInfo False else pure Nothing reactionAllowed :: Bool -> MsgReaction -> [MsgReaction] -> Bool @@ -3346,7 +3369,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = XMsgUpdate sharedMsgId mContent mentions ttl live msgScope asGroup_ -> void $ memberCanSend author_ msgScope $ groupMessageUpdate gInfo author_ sharedMsgId mContent mentions msgScope rcvMsg msgTs ttl live asGroup_ XMsgDel sharedMsgId memId scope_ -> void $ groupMessageDelete gInfo author_ sharedMsgId memId scope_ rcvMsg msgTs - XMsgReact sharedMsgId memId scope_ reaction add -> withAuthor XMsgReact_ $ \author -> void $ groupMsgReaction gInfo author sharedMsgId memId scope_ reaction add rcvMsg msgTs + XMsgReact sharedMsgId memId scope_ reaction add -> void $ groupMsgReaction gInfo author_ sharedMsgId memId scope_ reaction add rcvMsg msgTs XFileCancel sharedMsgId -> void $ xFileCancelGroup gInfo author_ sharedMsgId XInfo p -> withAuthor XInfo_ $ \author -> void $ xInfoMember gInfo author p rcvMsg msgTs XGrpMemNew memInfo msgScope -> withAuthor XGrpMemNew_ $ \author -> void $ xGrpMemNew gInfo author memInfo msgScope rcvMsg msgTs @@ -3528,7 +3551,25 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do withWorkItems a doWork (withStore' $ \db -> getNextDeliveryTasks db gInfo task) $ \nextTasks -> do let (body, taskIds, largeTaskIds) = batchDeliveryTasks1 vr maxEncodedMsgLength nextTasks withStore' $ \db -> do - createMsgDeliveryJob db gInfo jobScope (singleSenderGMId_ nextTasks) body + createMsgDeliveryJob db gInfo jobScope (singleSenderGMId_ nextTasks) body Nothing + forM_ taskIds $ \taskId -> updateDeliveryTaskStatus db taskId DTSProcessed + forM_ largeTaskIds $ \taskId -> setDeliveryTaskErrStatus db taskId "large" + lift . void $ getDeliveryJobWorker True deliveryKey + where + singleSenderGMId_ :: NonEmpty MessageDeliveryTask -> Maybe GroupMemberId + singleSenderGMId_ (MessageDeliveryTask {senderGMId = senderGMId'} :| ts) + | all (\MessageDeliveryTask {senderGMId} -> senderGMId == senderGMId') ts = Just senderGMId' + | otherwise = Nothing + DJReaction -> + withWorkItems a doWork (withStore' $ \db -> getNextDeliveryTasks db gInfo task) $ \nextTasks -> do + let (body, taskIds, largeTaskIds) = batchDeliveryTasks1 vr maxEncodedMsgLength nextTasks + subscriberBody = encodeBinaryBatch + [ encodeFwdElement (GrpMsgForward FwdChannel fwdBrokerTs) verifiedMsg + | MessageDeliveryTask {taskId = tId, brokerTs = fwdBrokerTs, verifiedMsg} <- L.toList nextTasks + , tId `elem` taskIds + ] + withStore' $ \db -> do + createMsgDeliveryJob db gInfo jobScope (singleSenderGMId_ nextTasks) body (Just subscriberBody) forM_ taskIds $ \taskId -> updateDeliveryTaskStatus db taskId DTSProcessed forM_ largeTaskIds $ \taskId -> setDeliveryTaskErrStatus db taskId "large" lift . void $ getDeliveryJobWorker True deliveryKey @@ -3545,7 +3586,7 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do fwd = GrpMsgForward {fwdSender, fwdBrokerTs} body = encodeBinaryBatch [encodeFwdElement fwd verifiedMsg] withStore' $ \db -> do - createMsgDeliveryJob db gInfo jobScope (Just senderGMId) body + createMsgDeliveryJob db gInfo jobScope (Just senderGMId) body Nothing updateDeliveryTaskStatus db (deliveryTaskId task) DTSProcessed lift . void $ getDeliveryJobWorker True deliveryKey @@ -3592,6 +3633,9 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do DJDeliveryJob _includePending -> do sendBodyToMembers withStore' $ \db -> updateDeliveryJobStatus db jobId DJSComplete + DJReaction -> do + sendBodyToMembersReaction + withStore' $ \db -> updateDeliveryJobStatus db jobId DJSComplete DJRelayRemoved | workerScope /= DWSGroup -> throwChatError $ CEInternalError "delivery job worker: relay removed job in wrong worker scope" @@ -3600,7 +3644,7 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do deleteGroupConnections user gInfo True withStore' $ \db -> updateDeliveryJobStatus db jobId DJSComplete where - MessageDeliveryJob {jobId, jobScope, singleSenderGMId_, body, cursorGMId_ = startingCursor} = job + MessageDeliveryJob {jobId, jobScope, singleSenderGMId_, body, subscriberBody_, cursorGMId_ = startingCursor} = job sendBodyToMembers :: CM () sendBodyToMembers -- channel @@ -3662,26 +3706,44 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do memberRole >= GRModerator && memberCurrent m && maxVersion (memberChatVRange m) >= groupKnockingVersion + sendBodyToMembersReaction :: CM () + sendBodyToMembersReaction + | useRelays' gInfo = do + bucketSize <- asks $ deliveryBucketSize . config + sendLoopReaction bucketSize startingCursor + | otherwise = -- fallback: deliver body to all (DJReaction should only be created for channels) + deliver body =<< withStore' (\db -> getGroupMembersByCursor db vr user gInfo startingCursor singleSenderGMId_ 0) where - deliver :: ByteString -> [GroupMember] -> CM () - deliver msgBody mems = - let mConns = mapMaybe (fmap snd . readyMemberConn) mems - msgReqs = foldMemConns mConns - in void $ withAgent (`sendMessages` msgReqs) + sendLoopReaction :: Int -> Maybe GroupMemberId -> CM () + sendLoopReaction bucketSize cursorGMId_ = do + mems <- withStore' $ \db -> getGroupMembersByCursor db vr user gInfo cursorGMId_ singleSenderGMId_ bucketSize + unless (null mems) $ do + let (owners, nonOwners) = partition (\m -> memberRole' m >= GROwner) mems + subBody = fromMaybe body subscriberBody_ + unless (null owners) $ deliver body owners + unless (null nonOwners) $ deliver subBody nonOwners + let cursorGMId' = groupMemberId' $ last mems + withStore' $ \db -> updateDeliveryJobCursor db jobId cursorGMId' + unless (length mems < bucketSize) $ sendLoopReaction bucketSize (Just cursorGMId') + deliver :: ByteString -> [GroupMember] -> CM () + deliver msgBody mems = + let mConns = mapMaybe (fmap snd . readyMemberConn) mems + msgReqs = foldMemConns mConns + in void $ withAgent (`sendMessages` msgReqs) + where + foldMemConns :: [Connection] -> [MsgReq] + foldMemConns mConns = snd $ foldr' addReq (lastMemIdx_, []) mConns where - foldMemConns :: [Connection] -> [MsgReq] - foldMemConns mConns = snd $ foldr' addReq (lastMemIdx_, []) mConns + lastMemIdx_ = let len = length mConns in if len > 1 then Just len else Nothing + addReq :: Connection -> (Maybe Int, [MsgReq]) -> (Maybe Int, [MsgReq]) + addReq conn (memIdx_, reqs) = + (subtract 1 <$> memIdx_, req : reqs) where - lastMemIdx_ = let len = length mConns in if len > 1 then Just len else Nothing - addReq :: Connection -> (Maybe Int, [MsgReq]) -> (Maybe Int, [MsgReq]) - addReq conn (memIdx_, reqs) = - (subtract 1 <$> memIdx_, req : reqs) - where - req = (aConnId conn, PQEncOff, MsgFlags False, vrValue_) - vrValue_ = case memIdx_ of - Nothing -> VRValue Nothing msgBody -- sending to one member, do not reference body - Just 1 -> VRValue (Just 1) msgBody - Just _ -> VRRef 1 + req = (aConnId conn, PQEncOff, MsgFlags False, vrValue_) + vrValue_ = case memIdx_ of + Nothing -> VRValue Nothing msgBody -- sending to one member, do not reference body + Just 1 -> VRValue (Just 1) msgBody + Just _ -> VRRef 1 -- Single worker processes all relay requests (XGrpRelayInv). -- We use map with a single key 1 to fit into existing worker management framework. diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index 393e008835..903b2718ce 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -62,12 +62,14 @@ jobScopeRow_ :: DeliveryJobScope -> DeliveryJobScopeRow jobScopeRow_ = \case DJSGroup {jobSpec} -> case jobSpec of DJDeliveryJob {includePending} -> (DWSGroup, Just DJSTDeliveryJob, Just (BI includePending), Nothing) + DJReaction -> (DWSGroup, Just DJSTReaction, Nothing, Nothing) DJRelayRemoved -> (DWSGroup, Just DJSTRelayRemoved, Nothing, Nothing) DJSMemberSupport {supportGMId} -> (DWSMemberSupport, Nothing, Nothing, Just supportGMId) toJobScope_ :: DeliveryJobScopeRow -> Maybe DeliveryJobScope toJobScope_ = \case (DWSGroup, Just DJSTDeliveryJob, Just (BI includePending), Nothing) -> Just $ DJSGroup {jobSpec = DJDeliveryJob {includePending}} + (DWSGroup, Just DJSTReaction, Nothing, Nothing) -> Just $ DJSGroup {jobSpec = DJReaction} (DWSGroup, Just DJSTRelayRemoved, Nothing, Nothing) -> Just $ DJSGroup {jobSpec = DJRelayRemoved} (DWSMemberSupport, Nothing, Nothing, Just supportGMId) -> Just $ DJSMemberSupport {supportGMId} _ -> Nothing @@ -180,7 +182,7 @@ getNextDeliveryTasks db gInfo task = MessageDeliveryTask {jobScope, senderGMId} = task getTaskIds :: IO [Int64] getTaskIds - | useRelays' gInfo = + | useRelays' gInfo, not (isSenderFiltered jobScope) = map fromOnly <$> DB.query db @@ -198,10 +200,10 @@ getNextDeliveryTasks db gInfo task = |] ((Only groupId) :. jobScopeRow_ jobScope :. (Only DTSNew)) | otherwise = - -- For fully connected groups we guarantee a singleSenderGMId for a delivery job by additionally filtering - -- on sender_group_member_id here, so that the job can then retrieve less members as recipients, - -- optimizing for this single sender (see processDeliveryJob -> fully connected group branch). - -- We do this optimization in the job to decrease load on admins using mobile devices for clients. + -- Filtering on sender_group_member_id guarantees a singleSenderGMId for the delivery job. + -- For fully connected groups this optimizes recipient retrieval (single sender exclusion). + -- For DJReaction in relay groups this is required to ensure the sender is excluded from + -- delivery, preventing subscribers from receiving their own reaction back via FwdChannel. map fromOnly <$> DB.query db @@ -219,6 +221,9 @@ getNextDeliveryTasks db gInfo task = ORDER BY delivery_task_id ASC |] ((Only groupId) :. jobScopeRow_ jobScope :. (senderGMId, DTSNew)) + isSenderFiltered :: DeliveryJobScope -> Bool + isSenderFiltered (DJSGroup {jobSpec = DJReaction}) = True + isSenderFiltered _ = False updateDeliveryTaskStatus :: DB.Connection -> Int64 -> DeliveryTaskStatus -> IO () updateDeliveryTaskStatus db taskId status = updateDeliveryTaskStatus_ db taskId status Nothing @@ -245,8 +250,8 @@ deleteDoneDeliveryTasks db createdAtCutoff = do |] (createdAtCutoff, DTSProcessed, DTSError) -createMsgDeliveryJob :: DB.Connection -> GroupInfo -> DeliveryJobScope -> Maybe GroupMemberId -> ByteString -> IO () -createMsgDeliveryJob db gInfo jobScope singleSenderGMId_ body = do +createMsgDeliveryJob :: DB.Connection -> GroupInfo -> DeliveryJobScope -> Maybe GroupMemberId -> ByteString -> Maybe ByteString -> IO () +createMsgDeliveryJob db gInfo jobScope singleSenderGMId_ body subscriberBody_ = do currentTs <- getCurrentTime DB.execute db @@ -254,10 +259,10 @@ createMsgDeliveryJob db gInfo jobScope singleSenderGMId_ body = do INSERT INTO delivery_jobs ( group_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - single_sender_group_member_id, body, job_status, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?) + single_sender_group_member_id, body, subscriber_body, job_status, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - ((Only groupId) :. jobScopeRow_ jobScope :. (singleSenderGMId_, Binary body, DJSPending, currentTs, currentTs)) + ((Only groupId) :. jobScopeRow_ jobScope :. (singleSenderGMId_, Binary body, Binary <$> subscriberBody_, DJSPending, currentTs, currentTs)) where GroupInfo {groupId} = gInfo @@ -272,7 +277,7 @@ getPendingDeliveryJobScopes db = |] (Only DJSPending) -type MessageDeliveryJobRow = (Only Int64) :. DeliveryJobScopeRow :. (Maybe GroupMemberId, Binary ByteString, Maybe GroupMemberId) +type MessageDeliveryJobRow = (Only Int64) :. DeliveryJobScopeRow :. (Maybe GroupMemberId, Binary ByteString, Maybe (Binary ByteString), Maybe GroupMemberId) getNextDeliveryJob :: DB.Connection -> DeliveryWorkerKey -> IO (Either StoreError (Maybe MessageDeliveryJob)) getNextDeliveryJob db deliveryKey = do @@ -302,16 +307,18 @@ getNextDeliveryJob db deliveryKey = do SELECT delivery_job_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - single_sender_group_member_id, body, cursor_group_member_id + single_sender_group_member_id, body, subscriber_body, cursor_group_member_id FROM delivery_jobs WHERE delivery_job_id = ? |] (Only jobId) where toDeliveryJob :: MessageDeliveryJobRow -> Either StoreError MessageDeliveryJob - toDeliveryJob ((Only jobId') :. jobScopeRow :. (singleSenderGMId_, Binary body, cursorGMId_)) = + toDeliveryJob ((Only jobId') :. jobScopeRow :. (singleSenderGMId_, Binary body, subscriberBodyBin_, cursorGMId_)) = case toJobScope_ jobScopeRow of - Just jobScope -> Right $ MessageDeliveryJob {jobId = jobId', jobScope, singleSenderGMId_, body, cursorGMId_} + Just jobScope -> + let subscriberBody_ = (\(Binary bs) -> bs) <$> subscriberBodyBin_ + in Right $ MessageDeliveryJob {jobId = jobId', jobScope, singleSenderGMId_, body, subscriberBody_, cursorGMId_} Nothing -> Left $ SEInvalidDeliveryJob jobId' markJobFailed :: Int64 -> IO () markJobFailed jobId = diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 5d433088a4..947ffb7917 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -94,6 +94,7 @@ module Simplex.Chat.Store.Messages getGroupCIReactions, getGroupReactions, setGroupReaction, + setGroupReactionNoMember, getReactionMembers, getChatItemIdsByAgentMsgId, getDirectChatItem, @@ -3443,6 +3444,31 @@ setGroupReaction db GroupInfo {groupId} m itemMemberId itemSharedMId sent reacti |] (groupId, groupMemberId' m, itemSharedMId, itemMemberId, BI sent, reaction) +setGroupReactionNoMember :: DB.Connection -> GroupInfo -> Maybe MemberId -> SharedMsgId -> MsgReaction -> Bool -> MessageId -> UTCTime -> IO () +setGroupReactionNoMember db GroupInfo {groupId} itemMemberId itemSharedMId reaction add msgId reactionTs + | add = + DB.execute + db + [sql| + INSERT INTO chat_item_reactions + (group_id, group_member_id, item_member_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts) + VALUES (?,NULL,?,?,0,?,?,?) + |] + (groupId, itemMemberId, itemSharedMId, reaction, msgId, reactionTs) + | otherwise = + DB.execute + db + [sql| + DELETE FROM chat_item_reactions + WHERE chat_item_reaction_id = ( + SELECT chat_item_reaction_id FROM chat_item_reactions + WHERE group_id = ? AND group_member_id IS NULL AND shared_msg_id = ? + AND item_member_id IS NOT DISTINCT FROM ? AND reaction_sent = 0 AND reaction = ? + LIMIT 1 + ) + |] + (groupId, itemSharedMId, itemMemberId, reaction) + getReactionMembers :: DB.Connection -> VersionRangeChat -> User -> GroupId -> SharedMsgId -> MsgReaction -> IO [MemberReaction] getReactionMembers db vr user groupId itemSharedMId reaction = do reactions <- diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 06efcdc17a..e9faa3f7bc 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -28,6 +28,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260108_chat_indices import Simplex.Chat.Store.Postgres.Migrations.M20260122_has_link import Simplex.Chat.Store.Postgres.Migrations.M20260222_chat_relays import Simplex.Chat.Store.Postgres.Migrations.M20260403_item_viewed +import Simplex.Chat.Store.Postgres.Migrations.M20260430_subscriber_body import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -55,7 +56,8 @@ schemaMigrations = ("20260108_chat_indices", m20260108_chat_indices, Just down_m20260108_chat_indices), ("20260122_has_link", m20260122_has_link, Just down_m20260122_has_link), ("20260222_chat_relays", m20260222_chat_relays, Just down_m20260222_chat_relays), - ("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed) + ("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed), + ("20260430_subscriber_body", m20260430_subscriber_body, Just down_m20260430_subscriber_body) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260430_subscriber_body.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260430_subscriber_body.hs new file mode 100644 index 0000000000..0baeccce86 --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260430_subscriber_body.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260430_subscriber_body where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260430_subscriber_body :: Text +m20260430_subscriber_body = + [r| +ALTER TABLE delivery_jobs ADD COLUMN subscriber_body BYTEA; +|] + +down_m20260430_subscriber_body :: Text +down_m20260430_subscriber_body = + [r| +ALTER TABLE delivery_jobs DROP COLUMN subscriber_body; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 41e6f0ed61..36503ce830 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -637,6 +637,7 @@ CREATE TABLE test_chat_schema.delivery_jobs ( job_scope_support_gm_id bigint, single_sender_group_member_id bigint, body bytea, + subscriber_body bytea, cursor_group_member_id bigint, job_status text NOT NULL, job_err_reason text, diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 607e0549b1..35b915f776 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -151,6 +151,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260108_chat_indices import Simplex.Chat.Store.SQLite.Migrations.M20260122_has_link import Simplex.Chat.Store.SQLite.Migrations.M20260222_chat_relays import Simplex.Chat.Store.SQLite.Migrations.M20260403_item_viewed +import Simplex.Chat.Store.SQLite.Migrations.M20260430_subscriber_body import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -301,7 +302,8 @@ schemaMigrations = ("20260108_chat_indices", m20260108_chat_indices, Just down_m20260108_chat_indices), ("20260122_has_link", m20260122_has_link, Just down_m20260122_has_link), ("20260222_chat_relays", m20260222_chat_relays, Just down_m20260222_chat_relays), - ("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed) + ("20260403_item_viewed", m20260403_item_viewed, Just down_m20260403_item_viewed), + ("20260430_subscriber_body", m20260430_subscriber_body, Just down_m20260430_subscriber_body) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260430_subscriber_body.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260430_subscriber_body.hs new file mode 100644 index 0000000000..1675e44028 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260430_subscriber_body.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260430_subscriber_body where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20260430_subscriber_body :: Query +m20260430_subscriber_body = + [sql| +ALTER TABLE delivery_jobs ADD COLUMN subscriber_body BLOB; +|] + +down_m20260430_subscriber_body :: Query +down_m20260430_subscriber_body = + [sql| +ALTER TABLE delivery_jobs DROP COLUMN subscriber_body; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 36f373f193..20821dcc63 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -732,6 +732,7 @@ CREATE TABLE delivery_jobs( job_scope_support_gm_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, single_sender_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE, body BLOB, + subscriber_body BLOB, cursor_group_member_id INTEGER, job_status TEXT NOT NULL, job_err_reason TEXT,