Skip to content

feat: gate DM read/played receipts on readreceipts privacy - #971

Merged
jlucaso1 merged 2 commits into
mainfrom
claude/readreceipts-privacy-gating
Jul 3, 2026
Merged

jlucaso1 merged 2 commits into
mainfrom
claude/readreceipts-privacy-gating

Conversation

@jlucaso1

@jlucaso1 jlucaso1 commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

What

When the user sets read receipts to off (readreceipts privacy = none), WhatsApp Web sends DM read/played receipts as read-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 notifying read/played form 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:

Chat kind Type
Newsletter *-self (unchanged)
Status / PSA current behavior (unchanged)
Group read / played — privacy does not apply to groups
DM *-self when readreceipts == none, else read / played

The same readreceipts setting governs both read and played receipts.

Implementation

Piece Where
read_receipts_disabled Device field (+ SetReadReceiptsDisabled command) wacore/src/store/device.rs, commands.rs
SQLite column + migration (mirrors lid_migrated: BOOLEAN NOT NULL DEFAULT 0) storages/sqlite-storage/
Populate from the privacy settings fetched on connect (was discarded) src/client/node_io.rs
DM gate in build_read_receipt_node / build_played_receipt_node src/receipt.rs

Why 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 readreceipts from persisted local prefs. Existing rows default to 0 (= all = send read, 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.
  • New tests: DM gates to *-self when disabled / stays plain when enabled, a group ignores the gate, a newsletter stays *-self regardless, plus the SetReadReceiptsDisabled apply 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

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.
@coderabbitai

coderabbitai Bot commented Jul 3, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@jlucaso1, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 10 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

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 configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 46bb3953-3390-4412-a9df-c844e40f38e3

📥 Commits

Reviewing files that changed from the base of the PR and between 6542806 and cb54296.

📒 Files selected for processing (2)
  • src/client/node_io.rs
  • src/receipt.rs
📝 Walkthrough

Walkthrough

Adds a persisted read_receipts_disabled flag to the device model, storage schema, and a new DeviceCommand variant to set it. Post-login sync compares fetched privacy settings against the device state and updates it. Receipt builders gate DM read/played receipts to emit self-variants when disabled.

Changes

Read Receipts Privacy Gating

Layer / File(s) Summary
Device model and command
wacore/src/store/device.rs, wacore/src/store/commands.rs
Adds read_receipts_disabled field to Device and a SetReadReceiptsDisabled(bool) DeviceCommand variant that mutates it, with Debug support and a unit test.
SQLite persistence
storages/sqlite-storage/migrations/2026-07-03-000001_add_read_receipts_privacy/*, storages/sqlite-storage/src/schema.rs, storages/sqlite-storage/src/sqlite_store.rs
Adds migration for the read_receipts_disabled column, updates Diesel schema, and wires save/create/load logic in SqliteStore.
Post-login privacy sync
src/client/node_io.rs
Compares fetched privacy settings against device snapshot and issues SetReadReceiptsDisabled with a persistence flush on mismatch.
Receipt builder gating and client wiring
src/receipt.rs
Extends build_played_receipt_node/build_read_receipt_node to emit played-self/read-self for disabled private DMs, wires mark_as_read/mark_as_played to pass the flag, and updates/adds tests.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • oxidezap/whatsapp-rust#737: Both PRs modify src/receipt.rs's played-receipt wire builder and Client::mark_as_played, so this PR's gating flag builds directly on that earlier implementation.

Suggested labels: api-design, breaking-change

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)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: gating DM read/played receipts on readreceipts privacy.
Description check ✅ Passed The description matches the changeset and explains the privacy gating, persistence, and scope accurately.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/readreceipts-privacy-gating

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 3, 2026

Copy link
Copy Markdown

Greptile Summary

Gates DM read/played receipts on the readreceipts privacy setting: when set to none, the client now sends read-self/played-self instead of read/played, matching WA Web behaviour and preventing unintended sender notification. The readreceipts value fetched on each connect is now persisted to SQLite so the correct type is used immediately on reconnect, before the per-connect privacy fetch completes.

  • Privacy gate (src/receipt.rs): both build_read_receipt_node and build_played_receipt_node compute is_private_dm excluding groups, status broadcasts, and broadcast lists, and downgrade to *-self only for true DMs when read_receipts_disabled is set. Five new tests cover DM/group/broadcast-list/newsletter × enabled/disabled.
  • Persistence layer: new read_receipts_disabled boolean on Device (with #[serde(default)]), a SetReadReceiptsDisabled command, a SQLite migration (DEFAULT 0), and a staleness-guarded, change-only write in the background init path.

Confidence Score: 5/5

Safe to merge — the broadcast-list exclusion raised in the previous review is now correctly implemented in both builders and covered by a dedicated test.

The privacy gate is logically correct: is_private_dm excludes groups, status broadcasts, and broadcast lists, matching the existing participant-attribute gate; newsletters are handled by the prior is_newsletter() short-circuit. The staleness guard in the background-init path prevents a superseded connection from overwriting a fresher value. Migration, schema, store, command, and Device field all align.

No files require special attention.

Important Files Changed

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
Loading
%%{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
Loading

Reviews (2): Last reviewed commit: "fix(receipts): exclude broadcast lists f..." | Re-trigger Greptile

Comment thread src/receipt.rs Outdated
Comment thread src/client/node_io.rs Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between dbdd240 and 6542806.

📒 Files selected for processing (8)
  • src/client/node_io.rs
  • src/receipt.rs
  • storages/sqlite-storage/migrations/2026-07-03-000001_add_read_receipts_privacy/down.sql
  • storages/sqlite-storage/migrations/2026-07-03-000001_add_read_receipts_privacy/up.sql
  • storages/sqlite-storage/src/schema.rs
  • storages/sqlite-storage/src/sqlite_store.rs
  • wacore/src/store/commands.rs
  • wacore/src/store/device.rs

Comment thread src/client/node_io.rs

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 stale read_receipts_disabled state is used, so mark_as_read/mark_as_played may 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 to read-self/played-self instead 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

Comment thread src/client/node_io.rs
Comment thread src/client/node_io.rs Outdated
Comment thread src/receipt.rs Outdated
Comment thread src/client/node_io.rs Outdated
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

📦 Binary size report

Metric main PR Δ
bin size (stripped) 10.74 MiB 10.74 MiB +2.69 KiB (+0.02%) 🔺
bin .text 8.74 MiB 8.75 MiB +2.25 KiB (+0.03%) 🔺
bin allocated (text+data+bss) 10.73 MiB 10.74 MiB +4.09 KiB (+0.04%) 🔺
llvm-lines wacore 503,148 503,173 +25 (+0.00%) 🔺
llvm-lines wacore copies 17,243 17,243 0
llvm-lines whatsapp-rust lib 737,616 737,937 +321 (+0.04%) 🔺
llvm-lines whatsapp-rust lib copies 23,865 23,865 0
deps crates (Cargo.lock) 466 466 0
.text per crate
Crate main PR Δ
.text whatsapp_rust 1.58 MiB 1.58 MiB +281 B (+0.02%) 🔺
.text wacore 530.89 KiB 530.93 KiB +40 B (+0.01%) 🔺
.text wacore_binary 157.49 KiB 157.49 KiB 0
.text wacore_libsignal 178.30 KiB 178.30 KiB 0
.text wacore_appstate 156.16 KiB 156.16 KiB 0
.text wacore_noise 26.05 KiB 26.05 KiB 0
.text waproto 1.60 MiB 1.60 MiB 0
.text whatsapp_rust_sqlite_storage 507.75 KiB 509.12 KiB +1.37 KiB (+0.27%) 🔺
.text whatsapp_rust_tokio_transport 43.50 KiB 43.50 KiB 0
.text whatsapp_rust_ureq_http_client 9.08 KiB 9.08 KiB 0
.text std 1020.91 KiB 1021.47 KiB +568 B (+0.05%) 🔺
.text other deps 2.94 MiB 2.94 MiB +5 B (+0.00%) 🔺
Top movers (cargo-bloat attribution)
Crate main PR Δ
whatsapp_rust_sqlite_storage 507.75 KiB 509.12 KiB +1.37 KiB (+0.27%)

Baseline: dbdd24038 (latest main run) · Head: 02c401c3b · Graphs

…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.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@jlucaso1
jlucaso1 merged commit 16298aa into main Jul 3, 2026
18 checks passed
@jlucaso1
jlucaso1 deleted the claude/readreceipts-privacy-gating branch July 3, 2026 23:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants