This commit is contained in:
spaced4ndy
2026-04-30 20:20:11 +04:00
parent 3cc2157d06
commit 9276ff32ee
14 changed files with 256 additions and 88 deletions
+12 -8
View File
@@ -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 {
+31 -17
View File
@@ -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 19291934) 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.
+2
View File
@@ -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:
+6
View File
@@ -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)
+8 -4
View File
@@ -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
+105 -43
View File
@@ -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.
+21 -14
View File
@@ -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 =
+26
View File
@@ -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 <-
@@ -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
@@ -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;
|]
@@ -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,
+3 -1
View File
@@ -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
@@ -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;
|]
@@ -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,