diff --git a/apps/ios/SimpleX NSE/NSEAPITypes.swift b/apps/ios/SimpleX NSE/NSEAPITypes.swift index 35a838fff9..28073ef342 100644 --- a/apps/ios/SimpleX NSE/NSEAPITypes.swift +++ b/apps/ios/SimpleX NSE/NSEAPITypes.swift @@ -89,6 +89,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult { case contactConnected(user: UserRef, contact: Contact, userCustomProfile: Profile?) case receivedContactRequest(user: UserRef, contactRequest: UserContactRequest) case newChatItems(user: UserRef, chatItems: [AChatItem]) + case duplicateGroupMessage(user: UserRef, member: GroupMember) case rcvFileSndCancelled(user: UserRef, chatItem: AChatItem, rcvFileTransfer: RcvFileTransfer) case sndFileComplete(user: UserRef, chatItem: AChatItem, sndFileTransfer: SndFileTransfer) case sndFileRcvCancelled(user: UserRef, chatItem_: AChatItem?, sndFileTransfer: SndFileTransfer) @@ -101,6 +102,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult { case .contactConnected: "contactConnected" case .receivedContactRequest: "receivedContactRequest" case .newChatItems: "newChatItems" + case .duplicateGroupMessage: "duplicateGroupMessage" case .rcvFileSndCancelled: "rcvFileSndCancelled" case .sndFileComplete: "sndFileComplete" case .sndFileRcvCancelled: "sndFileRcvCancelled" @@ -117,6 +119,7 @@ enum NSEChatEvent: Decodable, ChatAPIResult { case let .newChatItems(u, chatItems): let itemsString = chatItems.map { chatItem in String(describing: chatItem) }.joined(separator: "\n") return withUser(u, itemsString) + case let .duplicateGroupMessage(u, member): return withUser(u, String(describing: member)) case let .rcvFileSndCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileComplete(u, chatItem, _): return withUser(u, String(describing: chatItem)) case let .sndFileRcvCancelled(u, chatItem, _): return withUser(u, String(describing: chatItem)) diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 25df063f82..38e1b604be 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -73,6 +73,16 @@ public enum NSENotificationData { default: nil } } + + func replaces(_ bestAttempt: NSENotificationData) -> Bool { + switch (self, bestAttempt) { + case (.noNtf, .connectionEvent): true + case (.noNtf, _): false + case (.callInvitation, _): true + case (_, .callInvitation): false + default: true + } + } } // Once the last thread in the process completes processing chat controller is suspended, and the database is closed, to avoid @@ -573,9 +583,9 @@ class NotificationService: UNNotificationServiceExtension { // A better logic could be to check whether we have a call in the best attempt while processing .msgInfo marker above. // If the best attempt is a call, and its marker is received, and the call is recent (e.g., the last 30 seconds), it would deliver at once, // instead of requesting further messages. - if ntfEntity.msgBestAttemptNtf.callInvitation == nil || ntf.callInvitation != nil { + if let bestAttempt = notificationEntities[id]?.msgBestAttemptNtf, ntf.replaces(bestAttempt) { notificationEntities[id]?.msgBestAttemptNtf = ntf - } // otherwise keep call as best attempt + } return false } else { // We should not get to this branch, as notifications are not delivered for muted users. @@ -1022,8 +1032,8 @@ func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)? cItem = autoReceiveFile(file) ?? cItem } let ntf: NSENotificationData = (cInfo.ntfsEnabled(chatItem: cItem) && cItem.showNotification) ? .messageReceived(user, cInfo, cItem) : .noNtf - let chatIdOrMemberId = if case let .groupRcv(groupMember) = chatItem.chatItem.chatDir { - groupMember.id + let chatIdOrMemberId = if case let .group(groupInfo, _) = cInfo, let memberId = cItem.meta.forwardedByMember ?? cItem.chatDir.groupMemberId { + groupMemberEntityId(groupInfo.groupId, memberId) } else { chatItem.chatInfo.id } @@ -1031,6 +1041,8 @@ func receivedMsgNtf(_ res: NSEChatEvent) async -> (String, NSENotificationData)? } else { return nil } + case let .duplicateGroupMessage(_, member): + return (member.id, .noNtf) case let .rcvFileSndCancelled(_, aChatItem, _): cleanupFile(aChatItem) return nil diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index a8c5de3672..186d7dab64 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -3255,7 +3255,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable { public var relayLink: String? public var memberVerifiedCode: SecurityCode? - public var id: String { "#\(groupId) @\(groupMemberId)" } + public var id: String { groupMemberEntityId(groupId, groupMemberId) } public var ready: Bool { get { activeConn?.connStatus == .ready } } public var sndReady: Bool { get { ready || activeConn?.connStatus == .sndReady } } public var sendMsgEnabled: Bool { get { @@ -3444,6 +3444,10 @@ public struct GroupMember: Identifiable, Decodable, Hashable { ) } +public func groupMemberEntityId(_ groupId: Int64, _ groupMemberId: Int64) -> String { + "#\(groupId) @\(groupMemberId)" +} + public struct GroupSupportChat: Codable, Hashable { public var chatTs: Date public var unread: Int @@ -4217,6 +4221,10 @@ public enum CIDirection: Decodable, Hashable { } } + public var groupMemberId: Int64? { + if case let .groupRcv(groupMember) = self { groupMember.groupMemberId } else { nil } + } + public func sameDirection(_ dir: CIDirection) -> Bool { switch (self, dir) { case let (.groupRcv(m1), .groupRcv(m2)): m1.groupMemberId == m2.groupMemberId @@ -4242,6 +4250,7 @@ public struct CIMeta: Decodable, Hashable { public var userMention: Bool public var deletable: Bool public var editable: Bool + public var forwardedByMember: Int64? public var showGroupAsSender: Bool public var msgVerified: MsgVerified? diff --git a/bots/src/API/Docs/Events.hs b/bots/src/API/Docs/Events.hs index 48f307f6e4..e310fd54ef 100644 --- a/bots/src/API/Docs/Events.hs +++ b/bots/src/API/Docs/Events.hs @@ -203,6 +203,7 @@ undocumentedEvents = "CEvtContactRequestRejected", "CEvtContactSwitch", "CEvtCustomChatEvent", + "CEvtDuplicateGroupMessage", "CEvtGroupMemberRatchetSync", "CEvtGroupMemberSwitch", "CEvtServiceSubStatus", diff --git a/plans/2026-10-13-forwarded-notification-flag.md b/plans/2026-10-13-forwarded-notification-flag.md new file mode 100644 index 0000000000..a1962a6e0f --- /dev/null +++ b/plans/2026-10-13-forwarded-notification-flag.md @@ -0,0 +1,330 @@ +# Notification flag of forwarded messages + +Date: 2026-10-13. + +A message forwarded by a chat relay or by a forwarding group host is sent with the SMP notification flag when its original event has the flag by the rule of `hasNotification`. + +References are to `/code/simplex-chat-4`, branch `master`, commit `2f4a443dd`. Haskell paths without a directory are in `src/Simplex/Chat/`. `SMQ/` is `dist-newstyle/src/simplexmq-f058c38b0eecb6c0/src/Simplex/Messaging/`. `NSE` is `apps/ios/SimpleX NSE/NotificationService.swift`. + +## 1. Present behaviour +Path of a forwarded message: +``` +received Library/Subscriber.hs:1014 MSG of a member connection; the flag of the received SMP message is ignored + Library/Subscriber.hs:1069-1144 processEvent: a NewMessageDeliveryTask is returned for each event to forward +task Library/Subscriber.hs:1022 created when the user is the relay of a channel, or an admin or owner of a group (:3800), and the author is not blocked by an admin + Library/Subscriber.hs:1158-1176 createDeliveryTasks; createMsgDeliveryTask (Store/Delivery.hs:78) is called at :1165 and :1172 only + Store/Delivery.hs:137-170 the task is read with its message: msg_body is decoded to verifiedMsg, the original event +job Library/Subscriber.hs:4233-4240 DJDeliveryJob: accepted tasks of one scope (Store/Delivery.hs:178) are batched into one job (Messages/Batch.hs:84) + Library/Subscriber.hs:4246-4251 DJRelayRemoved: one job for one task + Library/Commands.hs:3256-3265 leaveChannelRelay: one job with the x.grp.leave of the relay + Store/Delivery.hs:285-328 the job is read as scope, sender ids, body and cursor; its events are stored only as the encoded body +sent Library/Subscriber.hs:4379-4423 channel: for each recipient, overflow profile batches, then the body; MsgFlags False (:4416) + Library/Subscriber.hs:4424-4487 channel support scope and regular group: deliver; MsgFlags False (:4483) +``` +Types (`Delivery.hs`): +``` +DeliveryTaskContext :48 + jobScope :: DeliveryJobScope + sentAsGroup :: ShowGroupAsSender +DeliveryJobScope :54 + DJSGroup {jobSpec :: DeliveryJobSpec} + DJSMemberSupport {supportGMId :: GroupMemberId} +DeliveryJobSpec :60 + DJDeliveryJob {includePending :: Bool} + DJRelayRemoved +NewMessageDeliveryTask :122 + messageId :: MessageId + taskContext :: DeliveryTaskContext +MessageDeliveryTask :128 + taskId :: Int64 + jobScope :: DeliveryJobScope + senderGMId :: GroupMemberId + fwdSender :: FwdSender + brokerTs :: UTCTime + verifiedMsg :: VerifiedMsg 'Json +MessageDeliveryJob :161 + jobId :: Int64 + jobScope :: DeliveryJobScope + senderGMIds :: [GroupMemberId] + body :: ByteString + cursorGMId_ :: Maybe GroupMemberId +``` +Tables (`Store/SQLite/Migrations/M20250813_delivery_tasks.hs:54, :103`, `M20260529_delivery_job_senders.hs:17`; the same columns in Postgres): +``` +both tables + group_id + worker_scope + job_scope_spec_tag + job_scope_include_pending + job_scope_support_gm_id + failed + created_at + updated_at +delivery_tasks + delivery_task_id + sender_group_member_id + message_id -- messages.chat_msg_event: the tag; messages.msg_body: the original event + message_from_channel + task_status + task_err_reason +delivery_jobs + delivery_job_id + sender_group_member_ids + body + cursor_group_member_id + job_status + job_err_reason +``` +The flag, from the forwarder to the notification: +``` +forwarder agent SMQ/Agent.hs:2186-2203, 2563 one flag for each MsgReq, stored with the sent message; a body shared by VRRef is sent with the flag of each request + SMQ/Agent.hs:2637-2644 the flag is a parameter of SEND +SMP server SMQ/Server.hs:2038-2039 a notification is stored for a flagged message to a queue with notifier credentials (:2131-2136), and for such a message only + SMQ/Server.hs:361-414 the stored notification is sent as NMSG to the subscriber of the notifier, the notification server +notification server SMQ/Notifications/Server.hs:545-553 NMSG: a push notification with the last notifications of the device token is sent to the push provider (:693-702) +recipient Library/Subscriber.hs:890 the notification subscription of a member connection is created unless the chat is muted, MFNone (Library/Commands.hs:1922-1929; SMQ/Agent.hs:3345-3361) + SMQ/Agent.hs:2093-2144, 3741 the agent gets the message of the notified queue and passes MSG with the flag; forwarded content is opaque to the agent + Library/Subscriber.hs:1014 the core ignores the flag; the item is created from the forwarded event (:4008-4064) +``` +iOS notification extension, for a forwarded message: +``` +entity key NSE:489-498 id of the notified connection: "# @" of the relay or the host (apps/ios/SimpleXChat/ChatTypes.swift:3624-3632, :3258) +item key NSE:1025-1029 the member of the item, its author, for groupRcv; "#" for channelRcv +forwarded item NSE:173-187 the keys are different: the notification of the item is added to droppedNotifications, outside the entity of the notified connection +shown NSE:1196-1216 the default of the connection: MFAll: title "# :", text "message received" (apps/ios/SimpleXChat/Notifications.swift:102); MFMentions: empty content +``` +- With the flag alone the push notification is sent, and the message is received by the core of the NSE: the item is created. The shown notification is the default one. The NSE change of section 7 is required for a notification with the message text. + +Regular groups, the same message sent to a member by its author and by the host: +``` +author Library/Internal.hs:2847-2865 member with a ready connection: sent to the member; otherwise forwarded by the host (MSAForwarded) or stored as pending +host Library/Subscriber.hs:4447-4458 job recipients: members whose relation with the sender is MRIntroduced +both sent while the author is connected to the member and the relation at the host is MRIntroduced + the second copy is a duplicate: one item; the recipient sends x.grp.mem.con (Library/Internal.hs:2939-2945, 2958-2968), the host stops forwarding (Library/Subscriber.hs:3724-3729) +rule each copy of x.msg.new is flagged: two notifications for one item in that interval, one otherwise +``` + +## 2. Rule +``` +forwarded message flag = hasNotification of the tag of its original event (Protocol.hs:1338) + the flag of the received SMP message is unused +SMP message notification = True when the flag of any forwarded message of its body is True, as for sent events (Library/Internal.hs:2248, 2748) +job one value for the body, computed when the job is created; the same for every recipient, scope and cursor page +channel pieces body (id 0) and extBody, profiles with the body (id 1): the job value + overflow profile batches (id 2 and above): False; their elements are x.grp.mem.new of the relay (Library/Subscriber.hs:4277) +``` +Events (handlers at `Library/Subscriber.hs:1080-1132`; forwarded events are those of `isForwardedGroupMsg`, `Protocol.hs:528`): +``` +event flag +x.msg.new True every content message, with or without a file +x.grp.del True forwarded by the relay-removed job (:1118) +x.msg.file.descr False +x.msg.update False +x.msg.del False +x.msg.react False +x.file.cancel False +x.info False +x.grp.relay.new False +x.grp.mem.new False +x.grp.mem.role False +x.grp.mem.restrict False +x.grp.mem.del False also the relay-removed job of a removed relay or host (:3746) +x.grp.leave False also the job of a relay that leaves (Library/Commands.hs:3263) +x.grp.info False +x.grp.prefs False +x.grp.roster False +x.file True handled without a delivery task (:1096) +x.grp.mem.fwd True handled without a delivery task (:1110) +x.grp.inv True handled without a delivery task (:1124) +x.call.inv True handled without a delivery task (:1132) +x.contact True handled without a delivery task (:1132) +``` + +## 3. Core changes +``` +Protocol.hs + msgHasNotification new, next to hasNotification (:1338): VerifiedMsg e -> Bool + hasNotification of the tag of the event of the message (verifiedChatMsg, :1583) +Delivery.hs + MessageDeliveryJob (:161) notification :: Bool -- new, after body +Store/Delivery.hs + createMsgDeliveryJob (:251) new parameter notification :: Bool, before body; written to delivery_jobs.notification + MessageDeliveryJobRow (:283) BoolInt added after the body + getNextDeliveryJob (:309-328) notification selected after body; set in MessageDeliveryJob +Library/Subscriber.hs + processDeliveryTask (:4235) notification: True when msgHasNotification of verifiedMsg is True for any of acceptedTasks; computed next to senderGMIds, passed at :4237 + (:4250) msgHasNotification verifiedMsg of the task is passed + processDeliveryJob (:4331) notification is bound from the job + recipientBodyPieces (:4417) each piece is returned with its flag: overflow batch False; extBody and body: notification + addPiece (:4410-4416) MsgFlags {notification = the flag of the piece} + deliver (:4483) MsgFlags {notification} +Library/Commands.hs + leaveChannelRelay (:3263) hasNotification XGrpLeave_ is passed +Library/Internal.hs + sendFwdMemberMessage (:2903) MsgFlags {notification = msgHasNotification verifiedMsg} + False for both callers: x.grp.roster (:1307), x.grp.mem.del (Library/Subscriber.hs:3798) +``` +Sites with a fixed flag, unchanged: +``` +Library/Internal.hs:1428 sendHistory: history for a new member, with the welcome message of a group; MsgFlags False (open question 1) +Library/Internal.hs:2459 sendGroupMemberMessages: events of the user to one member; notification = True +Library/Internal.hs:2910 sendPendingGroupMessages: pending messages of the user to one member; notification = True +tests/ChatTests/Groups.hs:5681, 10907, 13320, 13452, 13608, 13701 elements sent by tests through the agent; MsgFlags False +``` +- Every other `MsgFlags` value of the repository is built for events of the user, from `hasNotification` of their tags: `Library/Internal.hs:2248, 2552, 2748`; `Library/Commands.hs:2672, 4131`; `Library/Subscriber.hs:356`. +- Forwarded content is sent as `>` elements only (`encodeFwdElement`, `Messages/Batch.hs:125`; used at `:98`, `Library/Subscriber.hs:4248`, `Library/Internal.hs:1423, 2902`). `XGrpMsgForward` events are only received (`Library/Subscriber.hs:1127`). + +## 4. Storage +Migration `M20261012_delivery_job_notification`, SQLite and Postgres, registered in `Store/SQLite/Migrations.hs` (`:180`, `:358`), `Store/Postgres/Migrations.hs` (`:57`, `:112`) and `simplex-chat.cabal` (`:172`, `:351`). It is one `ADD COLUMN`; indexes are unchanged. +``` +delivery_jobs + notification INTEGER NOT NULL DEFAULT 0 -- SMALLINT in Postgres +``` +- Down migration: the column is dropped. `delivery_tasks` is unchanged: the event of a task is read with its message. +- Applied migrations must be a prefix of the list sorted by name (`SMQ/Agent/Store/Migrations.hs:23-34`; `Store/SQLite/Migrations.hs:363`). The name is ordered after `20261010_invite_member_via_group` and before the unreleased `20261013_chat_layout` and `20261013_msg_priority` of branch `ep/llm-ux` (open question 3). +- The schema dumps and `chat_query_plans.txt` are regenerated by the tests "verify and overwrite schema dump" (`tests/SchemaDump.hs:58`, `tests/PostgresSchemaDump.hs:29`) and "verify and overwrite query plans" (`tests/SchemaDump.hs:165`); they are never edited by hand. + +## 5. Compatibility and cost +``` +chat protocol unchanged; the flag is a parameter of SMP SEND (SMQ/Protocol.hs:598) +recipient every version; the flag is used by the SMP server only +earlier relay or host sends without the flag, as now; a channel member is notified by each relay of this release +job of an earlier release notification = 0: sent without the flag +downgrade the column is dropped +traffic count and size of SMP messages unchanged +notifications one for each flagged SMP message to a queue with notifier credentials: a device with a notification token and the chat set to MFAll or MFMentions + one job is one flagged SMP message for a recipient, for all its events +channel a post is sent by each relay to each member (Library/Internal.hs:2843-2845; Store/Delivery.hs:352) + with N members and R relays up to N × R notifications are sent for a post; one item is created (Library/Internal.hs:2958-2959) + shown on iOS: one notification for a post; a duplicate copy is shown with empty content (section 7) +profile overflow batches unflagged +``` + +## 6. Tests +`tests/ChatTests/Groups.hs`, helpers next to `relayConnIdToMember` (`:13140`): +``` +agentSendBody cc connId body body sent to the connection by the agent of cc, MsgFlags False; a failure is a test failure +lastSndMsgFlags cc connId n msg_flags, "T" or "F", of the last n sent rows of type A_MSG ("M") of agent table messages for the connection, in sending order; withCCAgentTransaction (tests/ChatTests/Utils.hs:675) +lastJobNotification cc notification of the last row of delivery_jobs; withCCTransaction +``` +- The agent row of a sent message is kept after delivery until its receipt (`SMQ/Agent/Store/AgentStore.hs:1318-1349`; `SMQ/Agent.hs:2866-2869`): recipients run `/set receipts all off` before the checked messages (as `:1561`). + +New tests: +``` +group message forwarding (:191) + testGroupMsgForwardNotification createGroup3, setupGroupForwarding (:5802); bob sends a message: alice to cath "T", job True; bob edits it: "F", job False +relay delivery, single relay (:283) + testChannels1RelayDeliverNotification createChannel1Relay (:9523); alice posts: bob to cath, dan and eve "T", job True; cath adds a reaction: bob to dan and eve "F", job False +deliver member profiles via relay (:295) + testChannelOverflowProfileNotification the scenario of testChannelLargeProfileFits (:9956) with an image of 10000 characters and a message of 7000 characters, so that the profile is sent in an overflow batch: bob to dan, the rows added by the message: "F" for the overflow profile batch, "T" for the message +``` +- One sender and one message give one job. +Assertions added to present tests, on `delivery_jobs` only (the connections and their agent rows are deleted after the job, `Library/Subscriber.hs:4328`): +``` +testGroupMsgForwardGroupDeletion (:6135) alice: job True, x.grp.del +testChannelDeleteGroupSigned (:11031) bob: job True, x.grp.del +testGroupMsgForwardAdminRemoval (:6096) alice: job False, x.grp.mem.del +testChannelRelayLeave (:11220) bob: job False, x.grp.leave +``` +Present tests of delivery jobs, unchanged: "group message forwarding" (`:191-206`), "group scoped messages" (`:255-264`), "relay delivery" (`:282-292`), "deliver member profiles via relay" (`:295-301`), `testRelayBatchAllLarge` and `testRelayBatchSingletonOverflow` (`tests/MessageBatching.hs:164, 186`). + +## 7. iOS notification of a forwarded message and of a duplicate copy +Event, `Controller.hs:1048`, next to `CEvtUnknownMemberAnnounced`: +``` +CEvtDuplicateGroupMessage JSON type duplicateGroupMessage + user :: User + member :: GroupMember -- the member whose connection delivered the copy +``` +Emission, `Library/Internal.hs`, before the present handling of the duplicate, only when the chat is started with `main=off` (the NSE and the share extension): +``` +isMainApp :2976 subscriptionMode is SMSubscribe; startChatController sets SMOnlyCreate for main=off (Library/Commands.hs:235) +saveGroupRcvMsg :2925 direct copy; member: the member of the connection; every SEDuplicateGroupMessage + x.grp.mem.con to the forwarding member when the stored copy was forwarded, as now (:2940-2944) +saveGroupFwdRcvMsg :2959 channel; member: the forwarding relay; Nothing is returned, as now + :2960-2969 group; member: the forwarding host; x.grp.mem.con and the error, as now +``` +Consumers: +``` +terminal view View.hs:471: no output, as CEvtNtfMessage (:547) +API docs bots/src/API/Docs/Events.hs:205: in undocumentedEvents, as CEvtUnknownMemberCreated +apps, CLI, bots not emitted: started with main=on +NSE decoded: case duplicateGroupMessage (SimpleX NSE/NSEAPITypes.swift:91) +``` +Swift types, `apps/ios/SimpleXChat/ChatTypes.swift`: +``` +CIMeta.forwardedByMember :4253 Int64?, after editable, as in core CIMeta (Messages.hs:525); omitted in JSON for Nothing +groupMemberEntityId :3447 (groupId, groupMemberId) -> "# @"; used by GroupMember.id (:3258) and the NSE +CIDirection.groupMemberId :4224 groupMemberId of the member of groupRcv; nil for other directions +``` + +NSE, `apps/ios/SimpleX NSE/NotificationService.swift`: +``` +receivedMsgNtf (:1035-1039) + newChatItems key of a group item: groupMemberEntityId of the group and of the member whose connection delivered the item: + forwardedByMember, otherwise chatDir.groupMemberId; the id of the connection entity (ChatTypes.swift:3624-3632) + other items, and channelRcv without forwardedByMember: chatInfo.id, as now + duplicateGroupMessage key: member.id; notification: .noNtf +processReceivedNtf (:586-588): the best attempt of the entity is read from notificationEntities[id] and replaced when ntf.replaces(bestAttempt) +NSENotificationData.replaces (:77-85), new notification, best attempt: + .noNtf, .connectionEvent true: the default of the connection + .noNtf, other false + .callInvitation, any true + other, .callInvitation false + other, other true +``` +- `.noNtf` is used: `.connectionEvent` is created only as the default (`defaultBestAttemptNtf`, `:1208-1229`). A received item, a contact event and a call are never replaced by `.noNtf`. +- The best attempt is read from `notificationEntities[id]`: `processDroppedNotifications` (`:514-529`) passes the entity copied before its notifications are processed. + +Results: +``` +forwarded post, MFAll the text of the post (createMessageReceivedNtf, apps/ios/SimpleXChat/Notifications.swift:66) +forwarded post, MFMentions the text for a mention or a reply; empty content otherwise +duplicate copy empty content, unless an item of the same push notification is shown +mention, then other item the mention is shown; before this patch, .noNtf of the other item replaced it +``` +Tests, `tests/ChatTests/Groups.hs`; helpers next to `lastJobNotification`: +``` +duplicateMessageEventsCfg testCfg with an eventHook (Controller.hs:257) that records the member names of CEvtDuplicateGroupMessage +setMainAppOff cc subscriptionMode of cc set to SMOnlyCreate, as by /_start main=off; called after the group is created +``` +Copies are sent through the agent of the sender by `agentSendBody`, as in `testGroupMsgForwardOwnMembershipRejected` (`:5666`), so that the order of copies is fixed: +``` +group message forwarding (:191) + testGroupMsgForwardDuplicateEvent createGroup3; bob's message is received by cath directly; alice's agent sends a forwarded copy + (Library/Internal.hs:2960-2969): cath "duplicate group message, group id: 1"; no event + setMainAppOff cath; alice's agent sends the copy again: the same line; event member alice + testGroupMsgDirectDuplicateEvent alice's agent sends a forwarded copy; setMainAppOff cath; bob's agent sends the direct copy + (Library/Internal.hs:2939-2946): cath "duplicate group message, group id: 1"; event member bob +relay delivery, single relay (:283) + testChannelDuplicateEvent createChannel1Relay; alice posts; setMainAppOff cath; bob's agent sends a second forwarded copy + (Library/Internal.hs:2959): event member bob; cath without output +``` +- The NSE and the Swift types are not compiled on this machine. Manual checks on iOS: a post in a channel with 2 relays, with MFAll and with MFMentions; a forwarded message in a group; a mention followed by an ordinary message in one push notification. + +## Interfaces +``` +core functions msgHasNotification :: VerifiedMsg e -> Bool + createMsgDeliveryJob, new parameter notification + isMainApp :: CM Bool +core types MessageDeliveryJob.notification +core events CEvtDuplicateGroupMessage, JSON type duplicateGroupMessage +storage delivery_jobs.notification + migration M20261012_delivery_job_notification +iOS CIMeta.forwardedByMember + groupMemberEntityId + CIDirection.groupMemberId + NSEChatEvent.duplicateGroupMessage + NSENotificationData.replaces +test helpers agentSendBody + lastSndMsgFlags + lastJobNotification + duplicateMessageEventsCfg + setMainAppOff +``` +- `eventHasNotification :: ChatMsgEvent e -> Bool` of `2026-10-13-message-priority.md` is accepted by one change: `msgHasNotification` is computed as `eventHasNotification` of the event of the message. Its three uses, the job column and `hasNotification XGrpLeave_` in `leaveChannelRelay` are unchanged. +- With that change a forwarded `x.msg.update` that raises priority is flagged. The line of the priority plan, section 4, "delivery jobs of relays and forwarding hosts, MsgFlags False for every message" is replaced by this plan. + +## Open questions +1. History and the welcome message (`sendHistory`), flagged until commit `93831a0f2` (2026-07-17) and unflagged since: apply the rule? Recommended: unchanged, sent without the flag. +2. The job flag as a column, or computed by the job worker from the decoded body, without a migration? Recommended: the column, computed where the events of the tasks are read. +3. Release order with `ep/llm-ux`. Recommended: this correction first, with date `20261012` in the migration name; otherwise a date after the last released migration. diff --git a/simplex-chat.cabal b/simplex-chat.cabal index c9a29b3ac8..205939a9a2 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -170,6 +170,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_bindings Simplex.Chat.Store.Postgres.Migrations.M20261010_invite_member_via_group + Simplex.Chat.Store.Postgres.Migrations.M20261012_delivery_job_notification else exposed-modules: Simplex.Chat.Archive @@ -349,6 +350,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_bindings Simplex.Chat.Store.SQLite.Migrations.M20261010_invite_member_via_group + Simplex.Chat.Store.SQLite.Migrations.M20261012_delivery_job_notification other-modules: Paths_simplex_chat hs-source-dirs: diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 2d553bdc9b..65c70e75fe 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -1046,6 +1046,7 @@ data ChatEvent | CEvtUnknownMemberCreated {user :: User, groupInfo :: GroupInfo, forwardedByMember :: GroupMember, member :: GroupMember} | CEvtUnknownMemberBlocked {user :: User, groupInfo :: GroupInfo, blockedByMember :: GroupMember, member :: GroupMember} | CEvtUnknownMemberAnnounced {user :: User, groupInfo :: GroupInfo, announcingMember :: GroupMember, unknownMember :: GroupMember, announcedMember :: GroupMember} + | CEvtDuplicateGroupMessage {user :: User, member :: GroupMember} | CEvtGroupDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember, msgSigned :: Maybe MsgSigStatus} | CEvtGroupUpdated {user :: User, fromGroup :: GroupInfo, toGroup :: GroupInfo, member_ :: Maybe GroupMember, msgSigned :: Maybe MsgSigStatus} -- there is the same command response | CEvtAcceptingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} diff --git a/src/Simplex/Chat/Delivery.hs b/src/Simplex/Chat/Delivery.hs index a6ccc74247..a83c624274 100644 --- a/src/Simplex/Chat/Delivery.hs +++ b/src/Simplex/Chat/Delivery.hs @@ -163,6 +163,7 @@ data MessageDeliveryJob = MessageDeliveryJob jobScope :: DeliveryJobScope, senderGMIds :: [GroupMemberId], body :: ByteString, + notification :: Bool, cursorGMId_ :: Maybe GroupMemberId } deriving (Show) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index c37c45ec90..7a4e6d2d36 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -3260,7 +3260,7 @@ processChatCommand cxt nm = \case withFastStore' $ \db -> do deleteGroupDeliveryTasks db gInfo deleteGroupDeliveryJobs db gInfo - createMsgDeliveryJob db gInfo (DJSGroup {jobSpec = DJRelayRemoved}) [] body + createMsgDeliveryJob db gInfo (DJSGroup {jobSpec = DJRelayRemoved}) [] (hasNotification XGrpLeave_) body lift . void $ getDeliveryJobWorker True (groupId, DWSGroup) pure msg leaveGroupSendMsg user g@(GIK gInfo _) = do diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 6ea428ac1f..418d454ada 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -2900,7 +2900,7 @@ sendFwdMemberMessage :: GroupMember -> GrpMsgForward -> VerifiedMsg 'Json -> CM sendFwdMemberMessage member fwd verifiedMsg = forM_ (readyMemberConn member) $ \(_, conn) -> do let body = encodeBinaryBatch [encodeFwdElement fwd verifiedMsg] - void $ withAgent $ \a -> sendMessages a [(aConnId conn, PQEncOff, MsgFlags False, VRValue Nothing body)] + void $ withAgent $ \a -> sendMessages a [(aConnId conn, PQEncOff, MsgFlags {notification = msgHasNotification verifiedMsg}, VRValue Nothing body)] -- TODO ensure order - pending messages interleave with user input messages sendPendingGroupMessages :: User -> GroupInfo -> GroupMember -> Connection -> CM () @@ -2937,11 +2937,13 @@ saveGroupRcvMsg user groupId authorMember conn@Connection {connId} agentMsgMeta msg <- withStore (\db -> createNewMessageAndRcvMsgDelivery db (GroupId groupId) newMsg sharedMsgId_ rcvMsgDelivery $ Just amGroupMemId) `catchAllErrors` \e -> case e of - ChatErrorStore (SEDuplicateGroupMessage _ _ _ (Just forwardedByGroupMemberId)) -> do - cxt <- chatStoreCxt - fm <- withStore $ \db -> getGroupMember db cxt user groupId forwardedByGroupMemberId - forM_ (memberConn fm) $ \fmConn -> - void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemId) groupId + ChatErrorStore (SEDuplicateGroupMessage _ _ _ forwardedByGroupMemberId_) -> do + unlessM isMainApp $ toView $ CEvtDuplicateGroupMessage user am' + forM_ forwardedByGroupMemberId_ $ \forwardedByGroupMemberId -> do + cxt <- chatStoreCxt + fm <- withStore $ \db -> getGroupMember db cxt user groupId forwardedByGroupMemberId + forM_ (memberConn fm) $ \fmConn -> + void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemId) groupId throwError e _ -> throwError e pure (am', conn', msg) @@ -2955,20 +2957,25 @@ saveGroupFwdRcvMsg user gInfo@GroupInfo {groupId} forwardingMember refAuthorMemb -- TODO [relays] TBC highlighting difference between deduplicated messages (useRelays branch) withStore' (\db -> runExceptT $ createNewRcvMessage db (GroupId groupId) newMsg sharedMsgId_ refAuthorId fwdMemberId) >>= \case Right msg -> pure $ Just msg - Left e@SEDuplicateGroupMessage {authorGroupMemberId, forwardedByGroupMemberId} - | useRelays' gInfo -> pure Nothing -- with chat relays, duplicates are expected - | otherwise -> case (authorGroupMemberId, forwardedByGroupMemberId) of - (Just authorGMId, Nothing) -> do - cxt <- chatStoreCxt - am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db cxt user groupId authorGMId - if maybe False (\ref -> sameMemberId (memberId' ref) am) refAuthorMember_ - then forM_ (memberConn forwardingMember) $ \fmConn -> - void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemberId) groupId - else toView $ CEvtMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id" - throwError $ ChatErrorStore e - _ -> throwError $ ChatErrorStore e + Left e@SEDuplicateGroupMessage {authorGroupMemberId, forwardedByGroupMemberId} -> do + unlessM isMainApp $ toView $ CEvtDuplicateGroupMessage user forwardingMember + if + | useRelays' gInfo -> pure Nothing -- with chat relays, duplicates are expected + | otherwise -> case (authorGroupMemberId, forwardedByGroupMemberId) of + (Just authorGMId, Nothing) -> do + cxt <- chatStoreCxt + am@GroupMember {memberId = amMemberId} <- withStore $ \db -> getGroupMember db cxt user groupId authorGMId + if maybe False (\ref -> sameMemberId (memberId' ref) am) refAuthorMember_ + then forM_ (memberConn forwardingMember) $ \fmConn -> + void $ sendDirectMemberMessage fmConn (XGrpMemCon amMemberId) groupId + else toView $ CEvtMessageError user "error" "saveGroupFwdRcvMsg: referenced author member id doesn't match message member id" + throwError $ ChatErrorStore e + _ -> throwError $ ChatErrorStore e Left e -> throwError $ ChatErrorStore e +isMainApp :: CM Bool +isMainApp = (SMSubscribe ==) <$> chatReadVar subscriptionMode + saveSndChatItem :: ChatTypeI c => User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> CM (ChatItem c 'MDSnd) saveSndChatItem user cd msg content = saveSndChatItem' user cd msg content Nothing Nothing Nothing Nothing False diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index a1e748831c..9047ca1532 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -4233,8 +4233,9 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do withWorkItems a doWork (withStore' $ \db -> getNextDeliveryTasks db gInfo task) $ \nextTasks -> do let (body_, acceptedTasks, largeTasks) = batchDeliveryTasks1 (vr cxt) maxForwardBatchLength nextTasks senderGMIds = S.toList . S.fromList $ map (\MessageDeliveryTask {senderGMId} -> senderGMId) acceptedTasks + notification = any (\MessageDeliveryTask {verifiedMsg} -> msgHasNotification verifiedMsg) acceptedTasks withStore' $ \db -> do - forM_ body_ $ \body -> createMsgDeliveryJob db gInfo jobScope senderGMIds body + forM_ body_ $ \body -> createMsgDeliveryJob db gInfo jobScope senderGMIds notification body forM_ acceptedTasks $ \t -> updateDeliveryTaskStatus db (deliveryTaskId t) DTSProcessed forM_ largeTasks $ \t -> setDeliveryTaskErrStatus db (deliveryTaskId t) "large" when (isJust body_) . lift . void $ getDeliveryJobWorker True deliveryKey @@ -4247,7 +4248,7 @@ runDeliveryTaskWorker a deliveryKey Worker {doWork} = do fwd = GrpMsgForward {fwdSender, fwdBrokerTs} body = encodeBinaryBatch [encodeFwdElement fwd verifiedMsg] withStore' $ \db -> do - createMsgDeliveryJob db gInfo jobScope [senderGMId] body + createMsgDeliveryJob db gInfo jobScope [senderGMId] (msgHasNotification verifiedMsg) body updateDeliveryTaskStatus db (deliveryTaskId task) DTSProcessed lift . void $ getDeliveryJobWorker True deliveryKey @@ -4328,7 +4329,7 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do deleteGroupConnections user gInfo True withStore' $ \db -> updateDeliveryJobStatus db jobId DJSComplete where - MessageDeliveryJob {jobId, jobScope, senderGMIds, body, cursorGMId_ = startingCursor} = job + MessageDeliveryJob {jobId, jobScope, senderGMIds, body, notification, cursorGMId_ = startingCursor} = job singleSenderGMId_ = case senderGMIds of [s] -> Just s _ -> Nothing @@ -4407,16 +4408,16 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do addRecipient acc r = case readyMemberConn r of Just (_, conn) -> snd $ foldl' (addPiece conn) (0 :: Int, acc) (recipientBodyPieces r) Nothing -> acc - addPiece conn (k, (issued, reqs)) (bid, msgBody) = + addPiece conn (k, (issued, reqs)) (bid, ntf, msgBody) = let vor | IS.member bid issued = VRRef bid | otherwise = VRValue (Just bid) msgBody issued' = IS.insert bid issued connId = if k == 0 then aConnId conn else B.empty - in (k + 1, (issued', (connId, PQEncOff, MsgFlags False, vor) : reqs)) + in (k + 1, (issued', (connId, PQEncOff, MsgFlags {notification = ntf}, vor) : reqs)) recipientBodyPieces r = - [(i, b) | (i, (b, ss)) <- overflowWithIds, any missing ss] - <> [if any missing inBodySenders then (1, extBody) else (0, body)] + [(i, False, b) | (i, (b, ss)) <- overflowWithIds, any missing ss] + <> [if any missing inBodySenders then (1, notification, extBody) else (0, notification, body)] where missing s = case M.lookup (groupMemberId' s) senderVec of Just vec -> getRelation (indexInGroup r) vec == MRNew @@ -4480,7 +4481,7 @@ runDeliveryJobWorker a deliveryKey Worker {doWork} = do addReq conn (memIdx_, reqs) = (subtract 1 <$> memIdx_, req : reqs) where - req = (aConnId conn, PQEncOff, MsgFlags False, vrValue_) + req = (aConnId conn, PQEncOff, MsgFlags {notification}, vrValue_) vrValue_ = case memIdx_ of Nothing -> VRValue Nothing msgBody -- sending to one member, do not reference body Just 1 -> VRValue (Just 1) msgBody diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index 1266104e0e..a4fb12cc72 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -1346,6 +1346,11 @@ hasNotification = \case XCallInv_ -> True _ -> False +msgHasNotification :: VerifiedMsg e -> Bool +msgHasNotification verifiedMsg = hasNotification $ toCMEventTag chatMsgEvent + where + ChatMessage {chatMsgEvent} = verifiedChatMsg verifiedMsg + hasDeliveryReceipt :: CMEventTag e -> Bool hasDeliveryReceipt = \case XMsgNew_ -> True diff --git a/src/Simplex/Chat/Store/Delivery.hs b/src/Simplex/Chat/Store/Delivery.hs index c80264da7a..c8875baab2 100644 --- a/src/Simplex/Chat/Store/Delivery.hs +++ b/src/Simplex/Chat/Store/Delivery.hs @@ -248,8 +248,8 @@ deleteDoneDeliveryTasks db createdAtCutoff = do |] (createdAtCutoff, DTSProcessed, DTSError) -createMsgDeliveryJob :: DB.Connection -> GroupInfo -> DeliveryJobScope -> [GroupMemberId] -> ByteString -> IO () -createMsgDeliveryJob db gInfo jobScope senderGMIds body = do +createMsgDeliveryJob :: DB.Connection -> GroupInfo -> DeliveryJobScope -> [GroupMemberId] -> Bool -> ByteString -> IO () +createMsgDeliveryJob db gInfo jobScope senderGMIds notification body = do currentTs <- getCurrentTime DB.execute db @@ -257,10 +257,10 @@ createMsgDeliveryJob db gInfo jobScope senderGMIds body = do INSERT INTO delivery_jobs ( group_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - sender_group_member_ids, body, job_status, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?) + sender_group_member_ids, body, notification, job_status, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) |] - ((Only groupId) :. jobScopeRow_ jobScope :. (senderColumn, Binary body, DJSPending, currentTs, currentTs)) + ((Only groupId) :. jobScopeRow_ jobScope :. (senderColumn, Binary body, BI notification, DJSPending, currentTs, currentTs)) where GroupInfo {groupId} = gInfo -- NULL ↔ []; non-empty list ↔ comma-separated decimal Int64s. @@ -280,7 +280,7 @@ getPendingDeliveryJobScopes db = |] (Only DJSPending) -type MessageDeliveryJobRow = (Only Int64) :. DeliveryJobScopeRow :. (Maybe Text, Binary ByteString, Maybe GroupMemberId) +type MessageDeliveryJobRow = (Only Int64) :. DeliveryJobScopeRow :. (Maybe Text, Binary ByteString, BoolInt, Maybe GroupMemberId) getNextDeliveryJob :: DB.Connection -> DeliveryWorkerKey -> IO (Either StoreError (Maybe MessageDeliveryJob)) getNextDeliveryJob db deliveryKey = do @@ -310,14 +310,14 @@ getNextDeliveryJob db deliveryKey = do SELECT delivery_job_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - sender_group_member_ids, body, cursor_group_member_id + sender_group_member_ids, body, notification, cursor_group_member_id FROM delivery_jobs WHERE delivery_job_id = ? |] (Only jobId) where toDeliveryJob :: MessageDeliveryJobRow -> Either StoreError MessageDeliveryJob - toDeliveryJob ((Only jobId') :. jobScopeRow :. (senderGMIdsText_, Binary body, cursorGMId_)) = do + toDeliveryJob ((Only jobId') :. jobScopeRow :. (senderGMIdsText_, Binary body, BI notification, cursorGMId_)) = do jobScope <- maybe (Left $ SEInvalidDeliveryJob jobId') Right $ toJobScope_ jobScopeRow -- NULL or empty string means []; otherwise the value must parse -- as a comma-separated decimal Int64 list. An unparseable @@ -325,7 +325,7 @@ getNextDeliveryJob db deliveryKey = do senderGMIds <- case senderGMIdsText_ of Nothing -> Right [] Just t -> maybe (Left $ SEInvalidDeliveryJob jobId') Right $ parseSenderGMIds t - Right $ MessageDeliveryJob {jobId = jobId', jobScope, senderGMIds, body, cursorGMId_} + Right $ MessageDeliveryJob {jobId = jobId', jobScope, senderGMIds, body, notification, cursorGMId_} parseSenderGMIds :: Text -> Maybe [GroupMemberId] parseSenderGMIds t | T.null t = Just [] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index dc2de08739..7fceb20dfe 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -55,6 +55,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260918_badge_issue_errors import Simplex.Chat.Store.Postgres.Migrations.M20260923_preferences_json import Simplex.Chat.Store.Postgres.Migrations.M20260925_badge_bindings import Simplex.Chat.Store.Postgres.Migrations.M20261010_invite_member_via_group +import Simplex.Chat.Store.Postgres.Migrations.M20261012_delivery_job_notification import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -109,7 +110,8 @@ schemaMigrations = ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors), ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings), - ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group) + ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group), + ("20261012_delivery_job_notification", m20261012_delivery_job_notification, Just down_m20261012_delivery_job_notification) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20261012_delivery_job_notification.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20261012_delivery_job_notification.hs new file mode 100644 index 0000000000..b8e025f53e --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20261012_delivery_job_notification.hs @@ -0,0 +1,19 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20261012_delivery_job_notification where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20261012_delivery_job_notification :: Text +m20261012_delivery_job_notification = + [r| +ALTER TABLE delivery_jobs ADD COLUMN notification SMALLINT NOT NULL DEFAULT 0; +|] + +down_m20261012_delivery_job_notification :: Text +down_m20261012_delivery_job_notification = + [r| +ALTER TABLE delivery_jobs DROP COLUMN notification; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 3e6d6694c6..6ed3e81d1f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -178,6 +178,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260918_badge_issue_errors import Simplex.Chat.Store.SQLite.Migrations.M20260923_preferences_json import Simplex.Chat.Store.SQLite.Migrations.M20260925_badge_bindings import Simplex.Chat.Store.SQLite.Migrations.M20261010_invite_member_via_group +import Simplex.Chat.Store.SQLite.Migrations.M20261012_delivery_job_notification import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -355,7 +356,8 @@ schemaMigrations = ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors), ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), ("20260925_badge_bindings", m20260925_badge_bindings, Just down_m20260925_badge_bindings), - ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group) + ("20261010_invite_member_via_group", m20261010_invite_member_via_group, Just down_m20261010_invite_member_via_group), + ("20261012_delivery_job_notification", m20261012_delivery_job_notification, Just down_m20261012_delivery_job_notification) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20261012_delivery_job_notification.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20261012_delivery_job_notification.hs new file mode 100644 index 0000000000..9a582823b5 --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20261012_delivery_job_notification.hs @@ -0,0 +1,18 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20261012_delivery_job_notification where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20261012_delivery_job_notification :: Query +m20261012_delivery_job_notification = + [sql| +ALTER TABLE delivery_jobs ADD COLUMN notification INTEGER NOT NULL DEFAULT 0; +|] + +down_m20261012_delivery_job_notification :: Query +down_m20261012_delivery_job_notification = + [sql| +ALTER TABLE delivery_jobs DROP COLUMN notification; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt index d12b3c98cf..dc8ad0f248 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/agent_query_plans.txt @@ -614,15 +614,6 @@ Query: Plan: SEARCH address_ratchet_keys USING INDEX idx_address_ratchet_keys (conn_id=? AND ratchet_key_id=?) -Query: - UPDATE rcv_messages - SET receive_attempts = receive_attempts + 1 - WHERE conn_id = ? AND internal_id = ? - RETURNING receive_attempts - -Plan: -SEARCH rcv_messages USING COVERING INDEX idx_rcv_messages_conn_id_internal_id (conn_id=? AND internal_id=?) - Query: DELETE FROM address_ratchet_keys WHERE conn_id = ? @@ -1431,6 +1422,10 @@ Query: SELECT last_insert_rowid() Plan: SCAN CONSTANT ROW +Query: SELECT msg_flags FROM messages WHERE conn_id = ? AND internal_snd_id IS NOT NULL AND msg_type = ? ORDER BY internal_id DESC LIMIT ? +Plan: +SEARCH messages USING PRIMARY KEY (conn_id=?) + Query: SELECT ratchet_state FROM ratchets WHERE conn_id = ? Plan: SEARCH ratchets USING PRIMARY KEY (conn_id=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 433c2ec43f..64aea20620 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -774,7 +774,7 @@ Query: SELECT delivery_job_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - sender_group_member_ids, body, cursor_group_member_id + sender_group_member_ids, body, notification, cursor_group_member_id FROM delivery_jobs WHERE delivery_job_id = ? @@ -5058,8 +5058,8 @@ Query: INSERT INTO delivery_jobs ( group_id, worker_scope, job_scope_spec_tag, job_scope_include_pending, job_scope_support_gm_id, - sender_group_member_ids, body, job_status, created_at, updated_at - ) VALUES (?,?,?,?,?,?,?,?,?,?) + sender_group_member_ids, body, notification, job_status, created_at, updated_at + ) VALUES (?,?,?,?,?,?,?,?,?,?,?) Plan: @@ -7838,6 +7838,10 @@ Query: SELECT note_folder_id FROM note_folders WHERE user_id = ? LIMIT 1 Plan: SEARCH note_folders USING COVERING INDEX note_folders_user_id (user_id=?) +Query: SELECT notification FROM delivery_jobs ORDER BY delivery_job_id DESC LIMIT 1 +Plan: +SCAN delivery_jobs + Query: SELECT purchase_key FROM badge_code_redemptions Plan: SCAN badge_code_redemptions diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index d60a41047f..71abf5d37f 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -806,7 +806,8 @@ CREATE TABLE delivery_jobs( created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')) , - sender_group_member_ids TEXT + sender_group_member_ids TEXT, + notification INTEGER NOT NULL DEFAULT 0 ) STRICT; CREATE TABLE group_member_status_predicates( member_status TEXT NOT NULL PRIMARY KEY, diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index e3e58cdf49..03b116202b 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -469,6 +469,7 @@ chatEventToView hu ChatConfig {logLevel, showReactions, showReceipts, testView} CEvtUnknownMemberCreated u g fwdM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember fwdM <> " forwarded a message from an unknown member, creating unknown member record " <> ttyMember um] CEvtUnknownMemberBlocked u g byM um -> ttyUser u [ttyGroup' g <> ": " <> ttyMember byM <> " blocked an unknown member, creating unknown member record " <> ttyMember um] CEvtUnknownMemberAnnounced u g _ um m -> ttyUser u [ttyGroup' g <> ": unknown member " <> ttyMember um <> " updated to " <> ttyMember m] + CEvtDuplicateGroupMessage {} -> [] CEvtRcvFileDescrReady _ _ _ _ -> [] CEvtRcvFileAccepted u ci -> ttyUser u $ savingFile' ci CEvtRcvFileAcceptedSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index a127b160f5..58c1c9c51c 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -18,7 +18,7 @@ import ChatTests.Profiles (addTestBadge, futureDate, issueTestBadge, testBadgeKe import ChatTests.Utils import Control.Concurrent (threadDelay) import Control.Concurrent.Async (concurrently_) -import Control.Concurrent.STM (atomically) +import Control.Concurrent.STM (TVar, atomically, modifyTVar', newTVarIO, readTVarIO, writeTVar) import Control.Monad (forM_, void, when) import Control.Monad.Except (runExceptT) import Control.Monad.Reader (runReaderT) @@ -32,14 +32,14 @@ import Data.List (intercalate, isInfixOf, isSuffixOf) import qualified Data.Map.Strict as M import qualified Data.Text as T import Simplex.Chat.Badges (FileSizeLimits (..)) -import Simplex.Chat.Controller (ChatController (ChatController, smpAgent), ChatConfig (..), ChatHooks (..), ChatLogLevel (..), defaultChatHooks, withAgent) +import Simplex.Chat.Controller (ChatController (ChatController, smpAgent, subscriptionMode), ChatConfig (..), ChatEvent (CEvtDuplicateGroupMessage), ChatHooks (..), ChatLogLevel (..), defaultChatHooks, withAgent) import Simplex.Chat.Library.Internal (encodeConnInfo, getShortLinkConnReq, uniqueMsgMentions, updatedMentionNames) import Simplex.Chat.Markdown (parseMaybeMarkdownList) import Simplex.Chat.Messages (CIMention (..), CIMentionMember (..), ChatItemId) import Simplex.Chat.Messages.Batch (encodeBinaryBatch, encodeFwdElement) import Simplex.Chat.Messages.CIContent (publicGroupNoE2EText) import Simplex.Chat.Options -import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XGrpRelayInv, XInfo, XMsgUpdate, XMsgNew, XMsgDel), FwdSender (FwdMember, FwdChannel), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), mcSimple, msgContentText) +import Simplex.Chat.Protocol (ChatMessage (ChatMessage), ChatMsgEvent (XGrpMemNew, XGrpRelayInv, XInfo, XMsgUpdate, XMsgNew, XMsgDel), FwdSender (FwdMember, FwdChannel), GrpMsgForward (GrpMsgForward), MsgContainer (..), MsgMention (..), MsgContent (..), VerifiedMsg (VMUnsigned), chatMsgToBody, mcSimple, msgContentText) import Simplex.Chat.Types import Simplex.Chat.Types.MemberRelations (MemberRelation (..), getRelation, setRelation) import Simplex.Chat.Types.Shared (GroupMemberRole (..), GroupAcceptance (..)) @@ -47,7 +47,7 @@ import Simplex.Messaging.Agent (joinConnection, prepareConnectionToJoin, sendMes import Simplex.Messaging.Agent.Env.SQLite import Simplex.Messaging.Agent.RetryInterval import qualified Simplex.Messaging.Agent.Store.DB as DB -import Simplex.Messaging.Agent.Store.DB (Binary (..)) +import Simplex.Messaging.Agent.Store.DB (Binary (..), BoolInt (..)) import qualified Simplex.Messaging.Crypto as C import Simplex.Messaging.Crypto.BBS (bbsKeyGen) import Simplex.Messaging.Client (pattern NRMInteractive) @@ -194,7 +194,10 @@ chatGroupTests = do it "forward batched messages" testGroupMsgForwardBatched it "forward reports to moderators, don't forward to members (x.msg.new, MCReport)" testGroupMsgForwardReport it "deduplicate forwarded messages" testGroupMsgForwardDeduplicate + it "report duplicate when forwarded copy is received after direct copy" testGroupMsgForwardDuplicateEvent + it "report duplicate when direct copy is received after forwarded copy" testGroupMsgDirectDuplicateEvent it "forward message edit (x.msg.update)" testGroupMsgForwardEdit + it "set notification flag of forwarded messages by the original event" testGroupMsgForwardNotification it "forward message reaction (x.msg.react)" testGroupMsgForwardReaction it "forward message deletion (x.msg.del)" testGroupMsgForwardDeletion it "forward file (x.msg.file.descr)" testGroupMsgForwardFile @@ -282,6 +285,8 @@ chatGroupTests = do describe "relay delivery" $ do describe "single relay" $ do it "should deliver messages to members" testChannels1RelayDeliver + it "should set notification flag of delivered messages" testChannels1RelayDeliverNotification + it "should report duplicate message delivered by relay" testChannelDuplicateEvent describe "should deliver messages in a loop over members" $ do it "number of recipients is multiple of bucket size (3/1)" (testChannels1RelayDeliverLoop 1) it "number of recipients is NOT multiple of bucket size (3/2)" (testChannels1RelayDeliverLoop 2) @@ -298,6 +303,7 @@ chatGroupTests = do it "multi senders disseminate independently" testChannelMultiSendersIndependent it "large profile fits in body" testChannelLargeProfileFits it "multiple large profiles pack across batches in one multi-sender job" testChannelMultipleLargeProfiles + it "overflow profile batch is sent without notification flag" testChannelOverflowProfileNotification it "profile update reuses existing announcement (no re-prepend)" testChannelProfileUpdateNoRePrepend describe "channel operations" $ do it "should update channel profile (signed)" testChannelUpdateProfileSigned @@ -5912,6 +5918,53 @@ testGroupMsgForwardDeduplicate = cath <#. "#team bob> hi there" cath <# "#team hey team" +testGroupMsgForwardDuplicateEvent :: HasCallStack => TestParams -> IO () +testGroupMsgForwardDuplicateEvent ps = do + (dups, cfg) <- duplicateMessageEventsCfg + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do + createGroup3 "team" alice bob cath + groupForwardingRelations alice "bob" "cath" `shouldEventuallyReturn` (MRConnected, MRConnected) + + bob #> "#team hi there" + [alice, cath] *<# "#team bob> hi there" + + sharedMsgId <- itemSharedMsgId cath + bobMemId <- memberIdByName cath "bob" + connId <- relayConnIdToMember alice "cath" + ts <- getCurrentTime + let chatMsg = ChatMessage chatInitialVRange (Just sharedMsgId) (XMsgNew $ mcSimple $ MCText "hi there") + sendForwardedCopy = agentSendBody alice connId $ encodeBinaryBatch [encodeFwdElement (GrpMsgForward (FwdMember bobMemId "bob") ts) (VMUnsigned chatMsg)] + sendForwardedCopy + cath <##. "duplicate group message, group id: 1" + readTVarIO dups `shouldReturn` [] + setMainAppOff cath + sendForwardedCopy + cath <##. "duplicate group message, group id: 1" + readTVarIO dups `shouldReturn` ["alice"] + +testGroupMsgDirectDuplicateEvent :: HasCallStack => TestParams -> IO () +testGroupMsgDirectDuplicateEvent ps = do + (dups, cfg) <- duplicateMessageEventsCfg + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChat ps "bob" bobProfile $ \bob -> + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> do + createGroup3 "team" alice bob cath + + bobMemId <- memberIdByName cath "bob" + aliceConnId <- relayConnIdToMember alice "cath" + bobConnId <- relayConnIdToMember bob "cath" + ts <- getCurrentTime + let chatMsg = ChatMessage chatInitialVRange (Just $ SharedMsgId "duplicate_msg1") (XMsgNew $ mcSimple $ MCText "hi there") + agentSendBody alice aliceConnId $ encodeBinaryBatch [encodeFwdElement (GrpMsgForward (FwdMember bobMemId "bob") ts) (VMUnsigned chatMsg)] + cath <# "#team bob> hi there [>>]" + + setMainAppOff cath + agentSendBody bob bobConnId $ chatMsgToBody chatMsg + cath <##. "duplicate group message, group id: 1" + readTVarIO dups `shouldReturn` ["bob"] + testGroupMsgForwardEdit :: HasCallStack => TestParams -> IO () testGroupMsgForwardEdit = testChat3 aliceProfile bobProfile cathProfile $ @@ -5936,6 +5989,29 @@ testGroupMsgForwardEdit = cath ##> "/tail #team 1" cath <# "#team bob> hello there [>>]" +testGroupMsgForwardNotification :: HasCallStack => TestParams -> IO () +testGroupMsgForwardNotification = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + setupGroupForwarding alice bob cath + cath ##> "/set receipts all off" + cath <## "ok" + cathConnId <- relayConnIdToMember alice "cath" + + bob #> "#team hi there" + alice <# "#team bob> hi there" + cath <# "#team bob> hi there [>>]" + lastSndMsgFlags alice cathConnId 1 `shouldReturn` ["T"] + lastJobNotification alice `shouldReturn` True + + bob ##> "! #team hello there" + bob <# "#team [edited] hello there" + alice <# "#team bob> [edited] hello there" + cath <# "#team bob> [edited] hello there" + lastSndMsgFlags alice cathConnId 1 `shouldReturn` ["F"] + lastJobNotification alice `shouldReturn` False + testGroupMsgForwardReaction :: HasCallStack => TestParams -> IO () testGroupMsgForwardReaction = testChat3 aliceProfile bobProfile cathProfile $ @@ -6118,6 +6194,7 @@ testGroupMsgForwardAdminRemoval = alice <## "use /d #team to delete the group", cath <## "#team: bob removed alice from the group (signed)" ] + lastJobNotification alice `shouldReturn` False -- there is no forwarding admin anymore between bob and cath, so messages don't get delivered -- (this is not a desired behavior, just a test demonstration/proof of current implementation) @@ -6159,6 +6236,7 @@ testGroupMsgForwardGroupDeletion = cath <## "#team: bob deleted the group (signed)" cath <## "use /d #team to delete the local copy of the group" ] + lastJobNotification alice `shouldReturn` True alice ##> "/groups" alice <## "#team (group deleted, delete local copy: /d #team)" @@ -9520,6 +9598,65 @@ testChannels1RelayDeliver ps = cath <## "group ID: 1" cath <## "subscribers: 4" +testChannels1RelayDeliverNotification :: HasCallStack => TestParams -> IO () +testChannels1RelayDeliverNotification ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + forM_ [cath, dan, eve] $ \cc -> do + cc ##> "/set receipts all off" + cc <## "ok" + cathConnId <- relayConnIdToMember bob "cath" + danConnId <- relayConnIdToMember bob "dan" + eveConnId <- relayConnIdToMember bob "eve" + + alice #> "#team hi" + bob <# "#team> hi" + [cath, dan, eve] *<# "#team> hi [>>]" + forM_ [cathConnId, danConnId, eveConnId] $ \connId -> + lastSndMsgFlags bob connId 1 `shouldReturn` ["T"] + lastJobNotification bob `shouldReturn` True + + cath ##> "+1 #team hi" + cath <## "added 👍" + bob <# "#team cath> > hi" + bob <## " + 👍" + alice <# "#team cath> > hi" + alice <## " + 👍" + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# "#team cath> > hi" + dan <## " + 👍" + eve <## "#team: bob introduced cath (Catherine) in the channel" + eve <# "#team cath> > hi" + eve <## " + 👍" + forM_ [danConnId, eveConnId] $ \connId -> + lastSndMsgFlags bob connId 1 `shouldReturn` ["F"] + lastJobNotification bob `shouldReturn` False + +testChannelDuplicateEvent :: HasCallStack => TestParams -> IO () +testChannelDuplicateEvent ps = do + (dups, cfg) <- duplicateMessageEventsCfg + withNewTestChat ps "alice" aliceProfile $ \alice -> + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> + withNewTestChatCfg ps cfg "cath" cathProfile $ \cath -> + withNewTestChat ps "dan" danProfile $ \dan -> + withNewTestChat ps "eve" eveProfile $ \eve -> do + createChannel1Relay "team" alice bob cath dan eve + alice #> "#team hi" + bob <# "#team> hi" + [cath, dan, eve] *<# "#team> hi [>>]" + + sharedMsgId <- itemSharedMsgId cath + connId <- relayConnIdToMember bob "cath" + ts <- getCurrentTime + let chatMsg = ChatMessage chatInitialVRange (Just sharedMsgId) (XMsgNew $ mcSimple $ MCText "hi") + setMainAppOff cath + agentSendBody bob connId $ encodeBinaryBatch [encodeFwdElement (GrpMsgForward FwdChannel ts) (VMUnsigned chatMsg)] + readTVarIO dups `shouldEventuallyReturn` ["bob"] + createChannel1Relay :: String -> TestCC -> TestCC -> TestCC -> TestCC -> TestCC -> IO () createChannel1Relay gName owner relay cath dan eve = do (shortLink, fullLink) <- prepareChannel1Relay gName owner relay @@ -9979,6 +10116,34 @@ testChannelLargeProfileFits ps = memberIntroducedTo bob "cath" "dan" +testChannelOverflowProfileNotification :: HasCallStack => TestParams -> IO () +testChannelOverflowProfileNotification ps = + withNewTestChat ps "alice" aliceProfile $ \alice -> do + withNewTestChatOpts ps relayTestOpts "bob" bobProfile $ \bob -> do + withNewTestChat ps "cath" cathProfile $ \cath -> do + withNewTestChat ps "dan" danProfile $ \dan -> do + (shortLink, fullLink) <- prepareChannel1Relay "team" alice bob + memberJoinChannel "team" [bob] [alice] shortLink fullLink cath + memberJoinChannel "team" [bob] [alice] shortLink fullLink dan + promoteChannelMember "team" alice bob cath [dan] + dan ##> "/set receipts all off" + dan <## "ok" + danConnId <- relayConnIdToMember bob "dan" + + let bigImage = T.pack ("data:image/png;base64," <> replicate 10000 'A') + longText = replicate 7000 'x' + withCCTransaction bob $ \db -> + DB.execute db "UPDATE contact_profiles SET image = ? WHERE display_name = ?" (bigImage, "cath" :: T.Text) + sentBefore <- length <$> lastSndMsgFlags bob danConnId 100 + + cath #> ("#team " <> longText) + bob <# ("#team cath> " <> longText) + alice <# ("#team cath> " <> longText <> " [>>]") + dan <### [EndsWith "updated to cath"] + dan <## "#team: bob introduced cath (Catherine) in the channel" + dan <# ("#team cath> " <> longText <> " [>>]") + drop sentBefore <$> lastSndMsgFlags bob danConnId 100 `shouldReturn` ["F", "T"] + testChannelMultipleLargeProfiles :: HasCallStack => TestParams -> IO () testChannelMultipleLargeProfiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -11053,6 +11218,7 @@ testChannelDeleteGroupSigned ps = eve <## "#team: alice deleted the group (signed)" eve <## "use /d #team to delete the local copy of the group" ] + lastJobNotification bob `shouldReturn` True testChannelDeleteGroupCleanup :: HasCallStack => TestParams -> IO () testChannelDeleteGroupCleanup ps = @@ -11246,6 +11412,7 @@ testChannelRelayLeave ps = dan <## "#team: bob left the group (signed)", eve <## "#team: bob left the group (signed)" ] + lastJobNotification bob `shouldReturn` False -- verify relay1 member status is "left" on all clients that know bob checkMemberStatus alice "bob" (Just "left") @@ -13149,6 +13316,39 @@ relayConnIdToMember cc name = do (Only connId : _) -> pure connId _ -> fail $ "no relay connection to member " <> T.unpack name +agentSendBody :: TestCC -> ByteString -> ByteString -> IO () +agentSendBody cc connId body = do + let ChatController {smpAgent} = chatController cc + runExceptT (sendMessages smpAgent [(connId, PQEncOff, MsgFlags False, vrValue body)]) >>= either (fail . show) (const $ pure ()) + +lastSndMsgFlags :: TestCC -> ByteString -> Int -> IO [T.Text] +lastSndMsgFlags cc connId n = do + rows <- withCCAgentTransaction cc $ \db -> + DB.query + db + "SELECT msg_flags FROM messages WHERE conn_id = ? AND internal_snd_id IS NOT NULL AND msg_type = ? ORDER BY internal_id DESC LIMIT ?" + (connId, Binary ("M" :: ByteString), n) + pure $ reverse $ map fromOnly rows + +lastJobNotification :: TestCC -> IO Bool +lastJobNotification cc = do + [Only (BI ntf)] <- withCCTransaction cc $ \db -> + DB.query_ db "SELECT notification FROM delivery_jobs ORDER BY delivery_job_id DESC LIMIT 1" + pure ntf + +duplicateMessageEventsCfg :: IO (TVar [ContactName], ChatConfig) +duplicateMessageEventsCfg = do + dups <- newTVarIO [] + let hook _ evt = do + case evt of + Right (CEvtDuplicateGroupMessage _ GroupMember {localDisplayName}) -> atomically $ modifyTVar' dups (<> [localDisplayName]) + _ -> pure () + pure evt + pure (dups, testCfg {chatHooks = defaultChatHooks {eventHook = Just hook}}) + +setMainAppOff :: TestCC -> IO () +setMainAppOff cc = atomically $ writeTVar (subscriptionMode $ chatController cc) SMOnlyCreate + itemSharedMsgId :: TestCC -> IO SharedMsgId itemSharedMsgId cc = do rows <- withCCTransaction cc $ \db ->