feat: gate DM read/played receipts on readreceipts privacy - #971
Conversation
When the user's readreceipts privacy is `none`, WhatsApp Web sends DM read and played receipts as `read-self`/`played-self` (which do not notify the sender) instead of `read`/`played`. The client previously always sent the notifying form for DMs, leaking read/played state regardless of the setting. - Persist the setting as a `read_receipts_disabled` Device field (column + migration), so the value is known immediately on every reconnect — no leak window while the per-connect privacy fetch is in flight. Populated from the privacy settings already fetched on connect (previously discarded); a change made on another device is picked up on the next connect. - Gate build_read_receipt_node / build_played_receipt_node: newsletters stay `*-self`, groups and status are exempt (privacy applies to DMs only), and a DM uses `*-self` when readreceipts is off. Grounded in WA Web ReadReceiptJob / PlayedReceiptJob. Tests: DM gates to *-self when disabled, stays plain when enabled, groups ignore the gate, newsletters stay self regardless; plus the command apply arm.
|
Warning Review limit reached
Next review available in: 10 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Repository UI (base), Organization UI (inherited) Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
📝 WalkthroughWalkthroughAdds a persisted ChangesRead Receipts Privacy Gating
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
Suggested labels: Look, this is a solid, focused change. We're shipping privacy controls that actually respect the read-receipts-off setting for DMs, and that's the kind of thing that needs to be airtight — no half-measures. The schema migration, the command, the device flag, the receipt gating — it's all connected end to end, and the tests cover the DM/group/newsletter cases properly. That's what "moving fast" should look like: fast, but correct. Ship it. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
| Filename | Overview |
|---|---|
| src/receipt.rs | Both build_played_receipt_node and build_read_receipt_node now correctly exclude broadcast-list JIDs from is_private_dm, matching the participant-attribute gate; new tests cover DM/group/broadcast-list/newsletter × enabled/disabled cases. |
| src/client/node_io.rs | Privacy settings are now consumed after the per-connect fetch: readreceipts==none is persisted via SetReadReceiptsDisabled; a staleness guard and a change-only write avoid persisting stale or redundant values. |
| wacore/src/store/device.rs | Adds read_receipts_disabled: bool with #[serde(default)] and Device::new() initialisation; backward-compatible with JSON-serialised snapshots and existing rows. |
| wacore/src/store/commands.rs | Adds SetReadReceiptsDisabled(bool) variant with apply_command_to_device arm and a round-trip test; follows existing command patterns exactly. |
| storages/sqlite-storage/src/sqlite_store.rs | Threads read_receipts_disabled through all three device write paths (upsert, conflict update, fresh insert) and the read path; no gaps. |
| storages/sqlite-storage/migrations/2026-07-03-000001_add_read_receipts_privacy/up.sql | Adds read_receipts_disabled BOOLEAN NOT NULL DEFAULT 0; existing rows default to false, matching Device::new(). |
| storages/sqlite-storage/migrations/2026-07-03-000001_add_read_receipts_privacy/down.sql | Drops the column; SQLite 3.35+ required for DROP COLUMN, consistent with other migrations in the project. |
| storages/sqlite-storage/src/schema.rs | Diesel schema updated with read_receipts_disabled -> Bool; order matches the migration column. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A[mark_as_read / mark_as_played] --> B[get_device_snapshot\n.read_receipts_disabled]
B --> C{build_*_receipt_node}
C --> D{chat.is_newsletter?}
D -- yes --> E[*-self]
D -- no --> F{is_private_dm?\nnot group/status/broadcast-list}
F -- no --> G[read / played]
F -- yes --> H{read_receipts_disabled?}
H -- false --> G
H -- true --> E
subgraph Per-connect background init
I[Fetch privacy settings] --> J{stale connection?}
J -- yes --> K[discard]
J -- no --> L{value changed?}
L -- no --> K
L -- yes --> M[SetReadReceiptsDisabled\n→ flush to SQLite]
end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
A[mark_as_read / mark_as_played] --> B[get_device_snapshot\n.read_receipts_disabled]
B --> C{build_*_receipt_node}
C --> D{chat.is_newsletter?}
D -- yes --> E[*-self]
D -- no --> F{is_private_dm?\nnot group/status/broadcast-list}
F -- no --> G[read / played]
F -- yes --> H{read_receipts_disabled?}
H -- false --> G
H -- true --> E
subgraph Per-connect background init
I[Fetch privacy settings] --> J{stale connection?}
J -- yes --> K[discard]
J -- no --> L{value changed?}
L -- no --> K
L -- yes --> M[SetReadReceiptsDisabled\n→ flush to SQLite]
end
Reviews (2): Last reviewed commit: "fix(receipts): exclude broadcast lists f..." | Re-trigger Greptile
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/client/node_io.rs`:
- Around line 849-871: The privacy refresh in the detached init path can leave
`read_receipts_disabled` stale after `Connected` is emitted, allowing
`mark_as_read` and `mark_as_played` to send the wrong receipt type. Move the
privacy-fetch and `DeviceCommand::SetReadReceiptsDisabled` update in
`node_io.rs` into the pre-`Connected` initialization path (or add an explicit
gate before read/played sends) so the persisted snapshot is applied, or safely
bypassed, before any caller can invoke receipt-sending methods.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI (base), Organization UI (inherited)
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: bf745b52-f9d7-4621-82bb-e60a0ba5fd5e
📒 Files selected for processing (8)
src/client/node_io.rssrc/receipt.rsstorages/sqlite-storage/migrations/2026-07-03-000001_add_read_receipts_privacy/down.sqlstorages/sqlite-storage/migrations/2026-07-03-000001_add_read_receipts_privacy/up.sqlstorages/sqlite-storage/src/schema.rsstorages/sqlite-storage/src/sqlite_store.rswacore/src/store/commands.rswacore/src/store/device.rs
There was a problem hiding this comment.
4 issues found across 8 files
Confidence score: 3/5
- In
src/client/node_io.rs, background privacy refresh during init/reconnect can leave a window where staleread_receipts_disabledstate is used, somark_as_read/mark_as_playedmay send receipts against the user’s latest privacy choice; this is the highest user-impact risk because it can leak read/play activity right after cross-device changes—gate receipt emission on confirmed fresh privacy state and add a generation/version check before persisting updates. - In
src/receipt.rs, the new DM predicate can treat broadcast-list chats like DMs when read-receipts privacy is disabled, downgrading receipts toread-self/played-selfinstead of broadcast behavior; merging as-is risks incorrect receipt semantics for broadcast lists—exclude broadcast lists explicitly in the predicate (or reuse existing broadcast classification) before merge. - In
src/client/node_io.rs, the long explanatory comment is low risk but hurts maintainability by obscuring the key reconnect leak-window rationale for future edits—trim it to a concise, why-focused note as a follow-up cleanup.
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
📦 Binary size report
.text per crate
Top movers (cargo-bloat attribution)
Baseline: |
…tale persist Review fixes on the readreceipts privacy gating: - Broadcast-list chats are group-adjacent (they carry a `participant` attr in the same builders), so the privacy gate must not downgrade them to `*-self`. Add `!chat.is_broadcast_list()` to the DM predicate in both builders + a regression test. - Re-check the connection generation before persisting the fetched readreceipts value, so a superseded background-init task can't write a now-stale value. - Trim the node_io comment to a concise why-focused note.
There was a problem hiding this comment.
0 issues found across 2 files (changes from recent commits).
Requires human review: Changes include a database migration and modifications to core DM receipt gating logic. Human review is required for these high-impact areas.
Re-trigger cubic
What
When the user sets read receipts to off (
readreceiptsprivacy =none), WhatsApp Web sends DM read/played receipts asread-self/played-self— which mark the message read on your own devices but do not notify the sender (no blue ticks). The client previously always sent the notifyingread/playedform for DMs, leaking read/played state regardless of the privacy setting. The setting was already fetched on connect but discarded.Follow-up from the WA Web parity audit (after #965, #968).
The gate (grounded in the bundle)
From
WAWeb/Send/ReadReceiptJob.js/PlayedReceiptJob.js, the receipt type is:*-self(unchanged)read/played— privacy does not apply to groups*-selfwhenreadreceipts == none, elseread/playedThe same
readreceiptssetting governs both read and played receipts.Implementation
read_receipts_disabledDevice field (+SetReadReceiptsDisabledcommand)wacore/src/store/device.rs,commands.rslid_migrated:BOOLEAN NOT NULL DEFAULT 0)storages/sqlite-storage/src/client/node_io.rsbuild_read_receipt_node/build_played_receipt_nodesrc/receipt.rsWhy persist it (vs a runtime cache): the value is then known immediately on every reconnect, so there is no leak window while the per-connect privacy fetch is still in flight. It mirrors WA Web, which reads
readreceiptsfrom persisted local prefs. Existing rows default to0(=all= sendread, the WA default). The fetch re-runs each connect, so a change made on another device is picked up on the next connect.Validation
cargo build --all✅,cargo clippy --all --tests✅ clean,cargo fmt --all.*-selfwhen disabled / stays plain when enabled, a group ignores the gate, a newsletter stays*-selfregardless, plus theSetReadReceiptsDisabledapply arm. Receipt suite 86/86, wacore commands 12/12, sqlite-storage 52/52 (migration applies).Not in scope
No dedicated readreceipts-change notification handler (there is no clean one; refresh is per-connect). No change to group/newsletter/status receipt behavior.
Generated by Claude Code