From 167a64ae6b29c30364cd6e1d8957056d432b61e5 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 4 Sep 2026 11:30:12 -0700 Subject: [PATCH 1/4] Review binary-payload-broadcast: verify MemoStateAction dedup, document Gherkin equivalents By architect. --- .../binary-payload-broadcast-summary.md | 84 +++++++++++++++++++ .../specs/binary-payload-broadcast.feature | 4 + psf-memo-client/src/services/memo-follow.js | 2 +- psf-memo-client/src/services/memo-mute.js | 2 +- .../src/services/memo-state-action.js | 2 +- 5 files changed, 91 insertions(+), 3 deletions(-) create mode 100644 docs/reviews/binary-payload-broadcast-summary.md diff --git a/docs/reviews/binary-payload-broadcast-summary.md b/docs/reviews/binary-payload-broadcast-summary.md new file mode 100644 index 0000000..aabbbc3 --- /dev/null +++ b/docs/reviews/binary-payload-broadcast-summary.md @@ -0,0 +1,84 @@ +# Architect Review — binary-payload-broadcast + +**Reviewed commits** +- `62e0479` Specify binary hash160 broadcast payload for follow/mute (specifier) +- `5e1f473` Broadcast follow/mute/unfollow/unmute hash160 payloads as Uint8Array (coder) +- `53f6b90` / `8d40f2b` Deduplicate Memo follow/mute onto shared MemoStateAction base (refactorer) + +**Task**: Follow/unfollow and mute/unmute must broadcast the target's raw +20-byte hash160 (not its display-form cash address text) as the OP_RETURN +payload, and must not depend on the Node-only `Buffer` global. The refactorer +also consolidated the previously near-identical `MemoFollow`/`MemoMute` action +classes onto a shared `MemoStateAction` base. + +## Architectural findings + +**UI/Core separation — good.** The follow/mute services remain pure logic over +injected `wallet`/`profiles` adapters. No UI, framework, or IO leaked into the +core; everything network/UI-specific stays behind the small wallet/profile +adapter boundaries. The modules remain fully testable without launching a UI or +network. + +**Dependency rule — good.** `MemoFollow`/`MemoMute` → `MemoStateAction` → +`MemoAction` → `hex`/`utf8`. High-level action semantics depend on low-level +byte/hex helpers through a stable base; the direction is inward. The base +subclasses (`MemoFollow`, `MemoMute`) are thin facades exposing only +`follow`/`unfollow` and `mute`/`unmute`. + +**Information hiding / encapsulation — good.** `MemoStateAction` encapsulates +the shared validate → toHash160 → hexToBytes → sendOpReturn → reflect +transition. Subclasses expose only config (`followConfig`/`muteConfig`) and +their two public methods; static exports and the public API are preserved. The +20-byte length lives on the base as `PK_HASH_LENGTH`, surfaced on both +subclasses. + +**Local code quality.** The refactor is a clean DRY extraction (~150 duplicate +lines removed). Two minor, pre-existing notes (not requiring change): +- Each config carries a `prefix` key that is unused (state actions pass the + prefix explicitly to `_setState`); harmless dead config carried over from the + prior `MemoAction` contract. +- `MemoStateAction.validate` throws a typed error whereas the base + `MemoAction.validate` returns `{ok:false}`; contract difference is intended + and documented in the module header. + +No architectural changes were required. The merged work confirms and preserves +the earlier binary `Uint8Array` broadcast behavior (`hexToBytes`) and the +Buffer-free production source. + +## Verification + +All per-component verification for `psf-memo-client` (the only touched +component) passed. + +- **Unit tests**: 280/280 pass +- **Property tests**: 40/40 pass (run separately, as required) +- **Lint**: clean (`standard`) +- **Acceptance**: all 23 generated feature suites pass, including the new + `binary-payload-broadcast.feature` +- **Language mutation** (`mutate4javascript --mutate-all --max-workers 8`): + - `memo-follow.js`: 2 killed, 0 survived, 0 uncovered + - `memo-mute.js`: 2 killed, 0 survived, 0 uncovered + - `memo-state-action.js` (new base): 5 killed, 0 survived, 0 uncovered + - `hex.js`/`memo-action.js` unchanged and unaffected. +- **DRY** (`dry4javascript`) on `memo-state-action.js`/`memo-follow.js`/ + `memo-mute.js`: no duplicate candidates. + +## Soft Gherkin acceptance mutation (survivors) + +`gherkin-mutator --level soft` over `binary-payload-broadcast.feature`: +4 mutations run, 4 survived, 0 killed, 0 errors. All four are single-character +**case** mutations of the shared example cash address (`q`→`Q`, `x`→`X`, +`c`→`C`, `k`→`K`). Each mutated example is applied consistently on both the +broadcast setup side and the assertion side of its scenario, so the scenario +still passes under the mutation (both sides derive from the same mutated +value). These are **genuine intrinsic equivalents**, not implementation gaps, +and match the documented read-only-payload survivor pattern. Documented here; +no chase performed. + +## Handoffs + +No functional handoffs sent. The work was a review of an already-functional +refactorer merge; my branch adds only the durable review summary plus the +tool-refreshed manifests (mutation `tested_at` timestamps and the new +acceptance-mutation manifest) from the verification runs. Function and module +hashes in the mutation manifests are unchanged, confirming no code drift. diff --git a/psf-memo-client/specs/binary-payload-broadcast.feature b/psf-memo-client/specs/binary-payload-broadcast.feature index c21cea9..49209b3 100644 --- a/psf-memo-client/specs/binary-payload-broadcast.feature +++ b/psf-memo-client/specs/binary-payload-broadcast.feature @@ -1,3 +1,7 @@ +# acceptance-mutation-manifest-begin +# {"version":1,"tested_at":"2026-09-04T18:29:23.460506768Z","feature_name":"Binary Payload Broadcast","feature_path":"../../psf-memo-client/specs/binary-payload-broadcast.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[]} +# acceptance-mutation-manifest-end + # Scenarios: Binary Payload Broadcast - 1, Binary Payload Broadcast - 2, Binary Payload Broadcast - 3, Binary Payload Broadcast - 4 # # Follow/unfollow and mute/unmute broadcast an OP_RETURN whose payload is the diff --git a/psf-memo-client/src/services/memo-follow.js b/psf-memo-client/src/services/memo-follow.js index 0f5e18a..5f7f069 100644 --- a/psf-memo-client/src/services/memo-follow.js +++ b/psf-memo-client/src/services/memo-follow.js @@ -55,5 +55,5 @@ MemoFollow.PK_HASH_LENGTH = MemoStateAction.PK_HASH_LENGTH module.exports = MemoFollow // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-09-04T18:22:54.857Z","module_hash":"cc36dc8387a54f50e91393bcb10382515e66a0cfc74fb4f51412fac4df580136","functions":[{"id":"func/followConfig","name":"followConfig","line":25,"end_line":35,"hash":"6843e108f72a95c18267d1ecea29fdfbdb0814ce4b5e3672f22df612e6ecaccd"},{"id":"func/MemoFollow.follow","name":"MemoFollow.follow","line":41,"end_line":43,"hash":"82635824a8e221e587b74b22fea454d2f661e599d8c9b9d0d7ec7c14e214cbd7"},{"id":"func/MemoFollow.unfollow","name":"MemoFollow.unfollow","line":46,"end_line":48,"hash":"fa99fa77d68a875092f5aad8323dca42de06dff49af469c6d7db94f7033399b5"}]} +// {"version":1,"tested_at":"2026-09-04T18:27:18.336Z","module_hash":"cc36dc8387a54f50e91393bcb10382515e66a0cfc74fb4f51412fac4df580136","functions":[{"id":"func/followConfig","name":"followConfig","line":25,"end_line":35,"hash":"6843e108f72a95c18267d1ecea29fdfbdb0814ce4b5e3672f22df612e6ecaccd"},{"id":"func/MemoFollow.follow","name":"MemoFollow.follow","line":41,"end_line":43,"hash":"82635824a8e221e587b74b22fea454d2f661e599d8c9b9d0d7ec7c14e214cbd7"},{"id":"func/MemoFollow.unfollow","name":"MemoFollow.unfollow","line":46,"end_line":48,"hash":"fa99fa77d68a875092f5aad8323dca42de06dff49af469c6d7db94f7033399b5"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/memo-mute.js b/psf-memo-client/src/services/memo-mute.js index 88ada11..d8f7f20 100644 --- a/psf-memo-client/src/services/memo-mute.js +++ b/psf-memo-client/src/services/memo-mute.js @@ -55,5 +55,5 @@ MemoMute.PK_HASH_LENGTH = MemoStateAction.PK_HASH_LENGTH module.exports = MemoMute // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-09-04T18:22:55.701Z","module_hash":"05cb4f14173db9584533e58f931d775a2505d844f53bd29724935c3de083c40b","functions":[{"id":"func/muteConfig","name":"muteConfig","line":25,"end_line":35,"hash":"f05b5b03c441735322cdb433417ae5351626ed4c7d1db0a94cbf350f6818a605"},{"id":"func/MemoMute.mute","name":"MemoMute.mute","line":41,"end_line":43,"hash":"ad4c0ae3933e732017a59814bc6b9f6e7d263669f59440f1010209403ce0066f"},{"id":"func/MemoMute.unmute","name":"MemoMute.unmute","line":46,"end_line":48,"hash":"758b2676537ce5861f94ca9efff5b98a946bd82c023964e4d0c40f90d0a1261a"}]} +// {"version":1,"tested_at":"2026-09-04T18:27:53.801Z","module_hash":"05cb4f14173db9584533e58f931d775a2505d844f53bd29724935c3de083c40b","functions":[{"id":"func/muteConfig","name":"muteConfig","line":25,"end_line":35,"hash":"f05b5b03c441735322cdb433417ae5351626ed4c7d1db0a94cbf350f6818a605"},{"id":"func/MemoMute.mute","name":"MemoMute.mute","line":41,"end_line":43,"hash":"ad4c0ae3933e732017a59814bc6b9f6e7d263669f59440f1010209403ce0066f"},{"id":"func/MemoMute.unmute","name":"MemoMute.unmute","line":46,"end_line":48,"hash":"758b2676537ce5861f94ca9efff5b98a946bd82c023964e4d0c40f90d0a1261a"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/memo-state-action.js b/psf-memo-client/src/services/memo-state-action.js index e80b02e..7c8cdc2 100644 --- a/psf-memo-client/src/services/memo-state-action.js +++ b/psf-memo-client/src/services/memo-state-action.js @@ -96,5 +96,5 @@ MemoStateAction.PK_HASH_LENGTH = PK_HASH_LENGTH module.exports = MemoStateAction // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-09-04T18:22:56.557Z","module_hash":"938e453562e0258b383cbc31f449fccc2e6105c805c97c52fb13bef3ee0fe6b4","functions":[{"id":"func/MemoStateAction.constructor","name":"MemoStateAction.constructor","line":25,"end_line":28,"hash":"9407b43605444074011b1da595c9d53356352c72b0d847e00365d79ad705663a"},{"id":"func/MemoStateAction.validate","name":"MemoStateAction.validate","line":32,"end_line":47,"hash":"4154af442c36324ba147dff352c55610397fb430c9b62fbbe44728ab0e379179"},{"id":"func/MemoStateAction._setState","name":"MemoStateAction._setState","line":51,"end_line":69,"hash":"86b976bcbe8532f7bfb9bdc989976f2266bce3d0e8c3a77a1f3e0b8413951684"},{"id":"func/MemoStateAction._toHash160","name":"MemoStateAction._toHash160","line":73,"end_line":75,"hash":"3ed2ba4853b718a8316345c9f988ed3bed3ef647de3fd9761ee3c9e0bee86070"},{"id":"func/MemoStateAction._ensureBchjs","name":"MemoStateAction._ensureBchjs","line":77,"end_line":81,"hash":"92828d9038c9a4d367e80edd7f129291e434d57aae80b332081a66af9896a5d6"},{"id":"func/MemoStateAction.reflect","name":"MemoStateAction.reflect","line":85,"end_line":91,"hash":"4ef0ab37e0ab08a681475805c5e53a010b35aa56560bc84f6d2189154ed7fa7d"}]} +// {"version":1,"tested_at":"2026-09-04T18:28:28.750Z","module_hash":"938e453562e0258b383cbc31f449fccc2e6105c805c97c52fb13bef3ee0fe6b4","functions":[{"id":"func/MemoStateAction.constructor","name":"MemoStateAction.constructor","line":25,"end_line":28,"hash":"9407b43605444074011b1da595c9d53356352c72b0d847e00365d79ad705663a"},{"id":"func/MemoStateAction.validate","name":"MemoStateAction.validate","line":32,"end_line":47,"hash":"4154af442c36324ba147dff352c55610397fb430c9b62fbbe44728ab0e379179"},{"id":"func/MemoStateAction._setState","name":"MemoStateAction._setState","line":51,"end_line":69,"hash":"86b976bcbe8532f7bfb9bdc989976f2266bce3d0e8c3a77a1f3e0b8413951684"},{"id":"func/MemoStateAction._toHash160","name":"MemoStateAction._toHash160","line":73,"end_line":75,"hash":"3ed2ba4853b718a8316345c9f988ed3bed3ef647de3fd9761ee3c9e0bee86070"},{"id":"func/MemoStateAction._ensureBchjs","name":"MemoStateAction._ensureBchjs","line":77,"end_line":81,"hash":"92828d9038c9a4d367e80edd7f129291e434d57aae80b332081a66af9896a5d6"},{"id":"func/MemoStateAction.reflect","name":"MemoStateAction.reflect","line":85,"end_line":91,"hash":"4ef0ab37e0ab08a681475805c5e53a010b35aa56560bc84f6d2189154ed7fa7d"}]} // mutate4javascript-manifest-end From b9d2be63edfaa92aa5dd5f43aa393e22f0e33d50 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 4 Sep 2026 11:34:03 -0700 Subject: [PATCH 2/4] Update briefing and backlog after binary-payload-broadcast merge By specifier. --- specifier-prompt.md | 22 +++++++++++++++++----- specs/feature-backlog.md | 8 ++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/specifier-prompt.md b/specifier-prompt.md index 40b793f..9172d0d 100644 --- a/specifier-prompt.md +++ b/specifier-prompt.md @@ -341,6 +341,17 @@ that a single user-facing feature may require specs in more than one component. following feed, topic feed, notifications, search, profile, recent profiles) use 50. The pure paginated controllers share a `PaginatedPage` base; profile, search, and recent-profiles gained Previous/Next controls in the same change. +19. **Do not use Node's `Buffer` global in client service code (real bug found).** + `memo-follow.js` and `memo-mute.js` used `Buffer.from(hash160, 'hex')` and + passed a Node `Buffer` to `wallet.sendOpReturn`; in a real browser `Buffer` + is undefined, so clicking Follow/Mute threw `Buffer is not defined` *after* + `getUtxos()` succeeded but *before* the transaction was composed. Node-based + unit/acceptance tests masked it because `Buffer` is a global under Node and + the fake wallet just recorded the passed value. Build binary Memo payloads + as a `Uint8Array` from the `./hex` `hexToBytes` helper (see `memo-reply.js`, + `memo-txid-action.js`, and now `memo-state-action.js`). To catch regressions, + unit-test that broadcast succeeds with `global.Buffer` temporarily deleted. + Fixed in the binary-payload-broadcast job (`984e691`). --- @@ -378,9 +389,10 @@ At the end of each session, update this file: - Note the current `master` HEAD commit. - State the next feature to work on. -Current `master` HEAD: `cfe6711` (merged architect's page-size-50 job — every -paginated page now requests 50 items instead of 100, pagination controls added -to search/profile/recent-profiles, controllers refactored onto `PaginatedPage`; -verified client build OK + 280 unit passing + 40 property passing + lint clean + -all 22 acceptance suites pass incl. the new page-size suite). +Current `master` HEAD: `984e691` (merged architect's binary-payload-broadcast job — +client follow/unfollow and mute/unmute broadcast the target's raw 20-byte +hash160 as a browser-safe `Uint8Array` payload, no longer crashing with +`Buffer is not defined`; follow/mute refactored onto a shared `MemoStateAction` +base. Verified client build OK + 280 unit passing + lint clean + all 23 +acceptance suites pass, including the new `binary-payload-broadcast` suite). Next action: **ask the user for the next front-end improvement to spec**. diff --git a/specs/feature-backlog.md b/specs/feature-backlog.md index 24560ae..01030b8 100644 --- a/specs/feature-backlog.md +++ b/specs/feature-backlog.md @@ -31,6 +31,14 @@ focus is **front-end improvements** to `psf-memo-client` (the React SPA). ## Recently completed +- **Binary hash160 broadcast payloads (2026-09-04):** client follow/unfollow + and mute/unmute now broadcast the target's raw 20-byte hash160 as the + OP_RETURN payload, built as a browser-safe `Uint8Array` from the `hexToBytes` + helper instead of Node's `Buffer` global (which crashed in a real browser + with `Buffer is not defined`). Follow/mute/unfollow/unmute were consolidated + onto a shared `MemoStateAction` base. Spec: + `psf-memo-client/specs/binary-payload-broadcast.feature`. Merged to `master` + at `984e691`. - **Page size 50 (2026-09-04):** every paginated page in the client now requests 50 items per page instead of 100 to cut payload size and improve page load times. Covers the recent feed, following feed, topic feed, notifications, search, From c8ef2ddd95d26e229bbda7d486824b52fed23041 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 4 Sep 2026 12:09:02 -0700 Subject: [PATCH 3/4] Spec mute feed filtering across recent, topic, search, and notifications feeds By specifier. --- .../specs/mute-feed-filtering.feature | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 psf-memo-client/specs/mute-feed-filtering.feature diff --git a/psf-memo-client/specs/mute-feed-filtering.feature b/psf-memo-client/specs/mute-feed-filtering.feature new file mode 100644 index 0000000..aecbb74 --- /dev/null +++ b/psf-memo-client/specs/mute-feed-filtering.feature @@ -0,0 +1,80 @@ +# Scenarios: Mute Feed Filtering - 1, Mute Feed Filtering - 2, Mute Feed Filtering - 3, Mute Feed Filtering - 4, Mute Feed Filtering - 5, Mute Feed Filtering - 6 +# +# Muting a profile hides that profile's content from the viewer's feeds. The +# psf-memo-db API filters server-side: given the viewer's address, it excludes +# posts and notifications authored by profiles the viewer currently mutes. The +# client identifies the viewer from the wallet and passes the viewer address to +# the recent feed, topic feed, search, and notifications queries. Filtering is +# not optimistic: a mute only takes effect once the mute transaction is mined +# and indexed, which is represented here by the psf-memo-db API reporting the +# mute. Unmuting restores the profile's content once the unmute is indexed. +Feature: Mute Feed Filtering + + Background: + Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d + + Scenario Outline: Mute Feed Filtering - 1 the recent feed hides posts from muted profiles + Given the psf-memo-db API reports that I mute the address + Given the psf-memo-db API serves a post with txid authored by the address with text + Given the psf-memo-db API serves a post with txid authored by the address with text + When I open the recent posts feed + Then the recent feed shows the post with text + And the recent feed does not show the post with text + + Examples: + | muted | txid_a | text_a | other | txid_b | text_b | + | bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy | 1111111111111111111111111111111111111111111111111111111111111111 | hello from muted | bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r | 2222222222222222222222222222222222222222222222222222222222222222 | hello from other | + | bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r | 3333333333333333333333333333333333333333333333333333333333333333 | muted again | bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy | 4444444444444444444444444444444444444444444444444444444444444444 | other again | + + Scenario Outline: Mute Feed Filtering - 2 the topic feed hides posts from muted profiles + Given the psf-memo-db API reports that I mute the address + Given the psf-memo-db API serves a post with txid in the topic "bitcoin" authored by the address with text + Given the psf-memo-db API serves a post with txid in the topic "bitcoin" authored by the address with text + When I open the topic feed for "bitcoin" + Then the topic feed shows the post with text + And the topic feed does not show the post with text + + Examples: + | muted | txid_a | text_a | other | txid_b | text_b | + | bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy | 1111111111111111111111111111111111111111111111111111111111111111 | muted topic post | bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r | 2222222222222222222222222222222222222222222222222222222222222222 | other topic post | + | bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r | 3333333333333333333333333333333333333333333333333333333333333333 | muted topic again | bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy | 4444444444444444444444444444444444444444444444444444444444444444 | other topic again | + + Scenario Outline: Mute Feed Filtering - 3 search hides posts from muted profiles + Given the psf-memo-db API reports that I mute the address + Given the psf-memo-db API has a post with the text authored by the address + Given the psf-memo-db API has a post with the text authored by the address + When I open the Search page + And I submit a search for + Then the search results include a post with the text + And the search results do not include a post with the text + + Examples: + | muted | text_a | other | text_b | query | + | bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy | muted post about bitcoin | bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r | other post about bitcoin | bitcoin | + | bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r | muted post about memo | bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy | other post about memo | memo | + + Scenario Outline: Mute Feed Filtering - 4 notifications hide actions from muted profiles + Given the psf-memo-db API reports that I mute the address + Given the psf-memo-db API serves a post with txid authored by my wallet address with text "hello from me" + Given the psf-memo-db API serves a reply with txid to the post with txid by the address with text + Given the psf-memo-db API serves a reply with txid to the post with txid by the address with text + When I open the Notifications page + Then the notifications include a reply notification from the address with text + And the notifications do not include a reply notification from the address + + Examples: + | my_post | reply_a | muted | text_a | reply_b | other | text_b | + | 1111111111111111111111111111111111111111111111111111111111111111 | 2222222222222222222222222222222222222222222222222222222222222222 | bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy | muted reply | 3333333333333333333333333333333333333333333333333333333333333333 | bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r | other reply | + | 4444444444444444444444444444444444444444444444444444444444444444 | 5555555555555555555555555555555555555555555555555555555555555555 | bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r | muted reply again | 6666666666666666666666666666666666666666666666666666666666666666 | bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy | other reply again | + + Scenario: Mute Feed Filtering - 5 unmuting restores posts in the recent feed + Given the psf-memo-db API reports that I mute the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy + Given the psf-memo-db API reports that I unmute the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy + Given the psf-memo-db API serves a post with txid 1111111111111111111111111111111111111111111111111111111111111111 authored by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "hello from alice" + When I open the recent posts feed + Then the recent feed shows the post with text "hello from alice" + + Scenario: Mute Feed Filtering - 6 the recent feed shows all posts when I mute no one + Given the psf-memo-db API serves a post with txid 1111111111111111111111111111111111111111111111111111111111111111 authored by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "hello from alice" + When I open the recent posts feed + Then the recent feed shows the post with text "hello from alice" From 448bddc31f35c627e7bd8d2e8f1debba38bc4259 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 4 Sep 2026 12:23:00 -0700 Subject: [PATCH 4/4] Implement mute feed filtering across recent, topic, search, and notifications feeds By coder. --- psf-memo-client/acceptance/lib/handlers.js | 124 ++++++++++++++++-- .../src/components/app-body/posts/index.js | 4 +- .../src/components/app-body/search/index.js | 4 +- psf-memo-client/src/services/memo-db.js | 18 ++- .../src/services/recent-feed-page.js | 20 +++ psf-memo-client/src/services/search-page.js | 10 +- .../src/services/topic-feed-page.js | 4 +- .../test/unit/recent-feed-page.test.js | 43 +++++- psf-memo-client/test/unit/search-page.test.js | 24 ++++ .../test/unit/topic-feed-page.test.js | 28 ++++ psf-memo-db/src/adapters/index.js | 12 +- .../src/adapters/notifications-query.js | 27 +++- psf-memo-db/src/adapters/post-query.js | 31 ++++- psf-memo-db/src/adapters/search-query.js | 15 ++- psf-memo-db/src/adapters/topic-query.js | 24 +++- .../controllers/rest-api/posts/controller.js | 7 +- .../controllers/rest-api/search/controller.js | 7 +- .../controllers/rest-api/topics/controller.js | 7 +- .../src/use-cases/list-recent-posts.js | 7 +- psf-memo-db/src/use-cases/list-topic-posts.js | 3 +- psf-memo-db/src/use-cases/search-all.js | 3 +- .../unit/adapters/notifications-query.unit.js | 36 +++++ .../test/unit/adapters/post-query.unit.js | 71 ++++++++++ .../test/unit/adapters/search-query.unit.js | 17 +++ .../test/unit/adapters/topic-query.unit.js | 25 +++- 25 files changed, 510 insertions(+), 61 deletions(-) diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index c8fdbf2..e38bc17 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -287,6 +287,9 @@ function makeMemoDb () { setMuteState (muterAddr, muteeAddr, muted) { muteState[`${muterAddr}:${muteeAddr}`] = muted }, + isMuted (muterAddr, muteeAddr) { + return muteState[`${muterAddr}:${muteeAddr}`] === true + }, setTopicFollowState (addr, room, following) { if (!topicFollow.has(room)) topicFollow.set(room, new Map()) topicFollow.get(room).set(addr, following) @@ -301,14 +304,17 @@ function makeMemoDb () { } return addrs }, - async search (q, { limit = 50, offset = 0 } = {}) { + async search (q, { limit = 50, offset = 0, viewer = null } = {}) { const normalized = String(q).trim().toLowerCase() if (normalized.length === 0) { return { posts: [], profiles: [], pagination: { total: 0, hasMore: false } } } - const matchedPosts = searchPosts.filter((p) => + let matchedPosts = searchPosts.filter((p) => typeof p.text === 'string' && p.text.toLowerCase().includes(normalized) ) + if (viewer) { + matchedPosts = matchedPosts.filter((p) => !this.isMuted(viewer, p.addr)) + } const matchedProfiles = searchProfiles.filter((p) => (typeof p.name === 'string' && p.name.toLowerCase().includes(normalized)) || (typeof p.text === 'string' && p.text.toLowerCase().includes(normalized)) @@ -321,9 +327,13 @@ function makeMemoDb () { pagination: { total, limit, offset, hasMore: offset + page.length < total } } }, - async getRecentPosts ({ limit = 50, offset = 0 } = {}) { - const page = posts.slice(offset, offset + limit) - return { posts: page, pagination: { total: posts.length, limit, offset, hasMore: offset + page.length < posts.length } } + async getRecentPosts ({ limit = 50, offset = 0, viewer = null } = {}) { + let filtered = posts + if (viewer) { + filtered = posts.filter((p) => !this.isMuted(viewer, p.addr)) + } + const page = filtered.slice(offset, offset + limit) + return { posts: page, pagination: { total: filtered.length, limit, offset, hasMore: offset + page.length < filtered.length } } }, async getPostsByAddr (addr, { limit = 50, offset = 0 } = {}) { const filtered = posts.filter((p) => p.addr === addr) @@ -347,8 +357,11 @@ function makeMemoDb () { list.sort((a, b) => a.room.localeCompare(b.room)) return { topics: list } }, - async getTopicPosts (room, { limit = 50, offset = 0 } = {}) { - const all = topicPosts[room] || [] + async getTopicPosts (room, { limit = 50, offset = 0, viewer = null } = {}) { + let all = topicPosts[room] || [] + if (viewer) { + all = all.filter((p) => !this.isMuted(viewer, p.addr)) + } const page = all.slice(offset, offset + limit) return { posts: page, pagination: { total: all.length, limit, offset, hasMore: offset + page.length < all.length } } }, @@ -359,6 +372,7 @@ function makeMemoDb () { const parent = posts.find((p) => p.txid === reply.parentTxid) if (!parent || parent.addr !== addr) continue if (reply.addr === addr) continue + if (this.isMuted(addr, reply.addr)) continue notifications.push({ type: 'reply', txid: reply.txid, @@ -373,6 +387,7 @@ function makeMemoDb () { const post = posts.find((p) => p.txid === like.postTxid) if (!post || post.addr !== addr) continue if (like.addr === addr) continue + if (this.isMuted(addr, like.addr)) continue notifications.push({ type: 'like', txid: like.txid, @@ -384,6 +399,7 @@ function makeMemoDb () { for (const follow of (followers.get(addr) || [])) { if (follow.followerAddr === addr) continue + if (this.isMuted(addr, follow.followerAddr)) continue notifications.push({ type: 'follow', txid: follow.txid, @@ -453,7 +469,7 @@ function createWorld () { } // Read-only page controllers backed by the fake psf-memo-db API. - world.recentFeedPage = new RecentFeedPage({ memoDb }) + world.recentFeedPage = new RecentFeedPage({ memoDb, wallet }) world.followingFeedPage = new FollowingFeedPage({ memoDb, wallet }) world.notificationsPage = new NotificationsPage({ memoDb, wallet }) world.profilePage = new ProfilePage({ memoDb }) @@ -464,6 +480,7 @@ function createWorld () { }) world.searchPage = new SearchPage({ memoDb, + wallet, navigate: (path) => { world.currentPath = path } }) world.recentProfilesPage = new RecentProfilesPage({ memoDb }) @@ -1435,6 +1452,28 @@ const handlers = [ world.renderedFeed = world.recentFeedPage.posts.map((post) => renderPostText(post.text)) } }, + { + name: 'recent feed shows post text', + pattern: /^the recent feed shows the post with text (.+)$/, + run (m, example, world) { + const expected = resolveText(m[1], example) + const found = world.recentFeedPage.posts.find((p) => p.text === expected) + if (!found) { + throw new Error(`Recent feed does not show a post with text "${expected}".`) + } + } + }, + { + name: 'recent feed does not show post text', + pattern: /^the recent feed does not show the post with text (.+)$/, + run (m, example, world) { + const expected = resolveText(m[1], example) + const found = world.recentFeedPage.posts.find((p) => p.text === expected) + if (found) { + throw new Error(`Recent feed unexpectedly shows a post with text "${expected}".`) + } + } + }, { name: 'open profile page for author', pattern: /^I open the profile page for the author of the post with txid (.+)$/, @@ -1661,6 +1700,15 @@ const handlers = [ world.memoDb.setMuteState(myAddr, addr, true) } }, + { + name: 'API reports I unmute address', + pattern: /^the psf-memo-db API reports that I unmute the address (.+)$/, + run (m, example, world) { + const addr = resolveParam(m[1], example) + const myAddr = world.wallet.walletInfo.cashAddress + world.memoDb.setMuteState(myAddr, addr, false) + } + }, { name: 'profile page shows Mute button', pattern: /^the profile page shows a Mute button$/, @@ -1798,12 +1846,12 @@ const handlers = [ }, { name: 'API serves post in topic with address and text', - pattern: /^the psf-memo-db API serves a post with txid (.+) in the topic "([^"]+)" authored by the address (.+) with text "(.+)"$/, + pattern: /^the psf-memo-db API serves a post with txid (.+) in the topic "([^"]+)" authored by the address (.+) with text (.+)$/, run (m, example, world) { const txid = resolveParam(m[1], example) const room = m[2] const addr = resolveParam(m[3], example) - const text = m[4] + const text = resolveText(m[4], example) world.memoDb.addTopicPost(room, { txid, addr, text, blockHeight: 100 }) } }, @@ -1904,6 +1952,28 @@ const handlers = [ } } }, + { + name: 'topic feed shows post with text', + pattern: /^the topic feed shows the post with text (.+)$/, + run (m, example, world) { + const expected = resolveText(m[1], example) + const found = world.topicFeedPage.posts.find((p) => p.text === expected) + if (!found) { + throw new Error(`Topic feed does not show a post with text "${expected}".`) + } + } + }, + { + name: 'topic feed does not show post with text', + pattern: /^the topic feed does not show the post with text (.+)$/, + run (m, example, world) { + const expected = resolveText(m[1], example) + const found = world.topicFeedPage.posts.find((p) => p.text === expected) + if (found) { + throw new Error(`Topic feed unexpectedly shows a post with text "${expected}".`) + } + } + }, { name: 'topic feed shows empty message', pattern: /^the feed shows a message that there are no posts$/, @@ -2296,6 +2366,16 @@ const handlers = [ }) } }, + { + name: 'API has search post with text and address', + pattern: /^the psf-memo-db API has a post with the text (.+) authored by the address (.+)$/, + run (m, example, world) { + const text = resolveText(m[1], example) + const addr = resolveParam(m[2], example) + const txid = require('crypto').createHash('sha256').update(`${text}:${addr}`).digest('hex') + world.memoDb.addSearchPost({ txid, addr, text, blockHeight: 100 }) + } + }, { name: 'API has search profile', pattern: /^the psf-memo-db API has a profile named "(.+)" with the bio "(.+)"$/, @@ -2333,6 +2413,17 @@ const handlers = [ } } }, + { + name: 'search results do not include post text', + pattern: /^the search results do not include a post with the text (.+)$/, + run (m, example, world) { + const expected = resolveText(m[1], example) + const found = world.searchPage.posts.find((p) => p.text === expected) + if (found) { + throw new Error(`Search results unexpectedly include a post with text "${expected}".`) + } + } + }, { name: 'search results include profile name', pattern: /^the search results include a profile named (.+)$/, @@ -2596,6 +2687,19 @@ const handlers = [ } } }, + { + name: 'notifications do not include reply notification', + pattern: /^the notifications do not include a reply notification from the address (.+)$/, + run (m, example, world) { + const expectedAddr = resolveParam(m[1], example) + const found = world.notificationsPage.notifications.find((n) => + n.type === 'reply' && n.addr === expectedAddr + ) + if (found) { + throw new Error(`Notifications unexpectedly include a reply from ${expectedAddr}.`) + } + } + }, { name: 'notifications include like notification', pattern: /^the notifications include a like notification from the address (.+)$/, diff --git a/psf-memo-client/src/components/app-body/posts/index.js b/psf-memo-client/src/components/app-body/posts/index.js index 0123287..592fe1a 100644 --- a/psf-memo-client/src/components/app-body/posts/index.js +++ b/psf-memo-client/src/components/app-body/posts/index.js @@ -48,9 +48,11 @@ function RecentPosts (props) { try { const memoDb = new MemoDb() + const viewer = appData?.wallet?.walletInfo?.cashAddress const data = await memoDb.getRecentPosts({ limit: PAGE_SIZE, - offset + offset, + viewer }) const loadedPosts = data.posts || [] diff --git a/psf-memo-client/src/components/app-body/search/index.js b/psf-memo-client/src/components/app-body/search/index.js index 3af6089..47e87cd 100644 --- a/psf-memo-client/src/components/app-body/search/index.js +++ b/psf-memo-client/src/components/app-body/search/index.js @@ -79,7 +79,7 @@ function Search (props) { try { const memoDb = new MemoDb() - const page = new SearchPage({ memoDb }) + const page = new SearchPage({ memoDb, wallet: props.appData?.wallet }) page.setQuery(query) const result = await page.submit({ limit: PAGE_SIZE, offset: 0 }) setPosts(result.posts || []) @@ -104,7 +104,7 @@ function Search (props) { try { const memoDb = new MemoDb() - const page = new SearchPage({ memoDb }) + const page = new SearchPage({ memoDb, wallet: props.appData?.wallet }) page.setQuery(query) const result = await page.submit({ limit: PAGE_SIZE, offset: nextOffset }) setPosts(result.posts || []) diff --git a/psf-memo-client/src/services/memo-db.js b/psf-memo-client/src/services/memo-db.js index 4f77965..93c7f92 100644 --- a/psf-memo-client/src/services/memo-db.js +++ b/psf-memo-client/src/services/memo-db.js @@ -14,8 +14,10 @@ class MemoDb { return this.getRecent('/profile/recent', 'getRecentProfiles', { limit, offset }) } - async getRecentPosts ({ limit = 50, offset = 0 } = {}) { - return this.getRecent('/posts/recent', 'getRecentPosts', { limit, offset }) + async getRecentPosts ({ limit = 50, offset = 0, viewer = null } = {}) { + const params = { limit, offset } + if (viewer) params.viewer = viewer + return this.getRecent('/posts/recent', 'getRecentPosts', params) } async getProfile (addr) { @@ -46,8 +48,10 @@ class MemoDb { return this.getRecent('/topics', 'getTopics', {}) } - async getTopicPosts (room, opts = {}) { - return this.getPage(`/topics/${encodeURIComponent(room)}/posts`, 'getTopicPosts', opts) + async getTopicPosts (room, { limit = 50, offset = 0, viewer = null } = {}) { + const params = { limit, offset } + if (viewer) params.viewer = viewer + return this.getPage(`/topics/${encodeURIComponent(room)}/posts`, 'getTopicPosts', params) } async getTopicFollowState (room, addr) { @@ -58,10 +62,12 @@ class MemoDb { return this._getList(`/topics/${encodeURIComponent(room)}/followers`, 'getTopicFollowers', 'followers') } - async search (q, { limit = 50, offset = 0 } = {}) { + async search (q, { limit = 50, offset = 0, viewer = null } = {}) { try { + const params = { q, limit, offset } + if (viewer) params.viewer = viewer const result = await this.axios.get(`${config.backend}/search`, { - params: { q, limit, offset } + params }) return result.data diff --git a/psf-memo-client/src/services/recent-feed-page.js b/psf-memo-client/src/services/recent-feed-page.js index 341a628..2d16930 100644 --- a/psf-memo-client/src/services/recent-feed-page.js +++ b/psf-memo-client/src/services/recent-feed-page.js @@ -21,6 +21,26 @@ class RecentFeedPage extends PaginatedPage { loadMethod: 'getRecentPosts', errorMessage: 'Recent feed page requires a memo db client.' }) + this.wallet = deps.wallet || null + } + + getMyAddress () { + return this.wallet?.walletInfo?.cashAddress || null + } + + async load ({ limit = 50, offset = 0 } = {}) { + if (!this.memoDb) { + throw new Error('Recent feed page requires a memo db client.') + } + + const opts = { limit, offset } + const viewer = this.getMyAddress() + if (viewer) opts.viewer = viewer + const data = await this.memoDb.getRecentPosts(opts) + this.posts = data.posts || [] + this.pagination = data.pagination || null + + return { posts: this.posts, pagination: this.pagination } } getPost (txid) { diff --git a/psf-memo-client/src/services/search-page.js b/psf-memo-client/src/services/search-page.js index a0b174c..c2f83fa 100644 --- a/psf-memo-client/src/services/search-page.js +++ b/psf-memo-client/src/services/search-page.js @@ -11,6 +11,7 @@ const SEARCH_PATH = '/search' class SearchPage { constructor (deps = {}) { this.memoDb = deps.memoDb || null + this.wallet = deps.wallet || null this.navigate = deps.navigate || (() => {}) this.query = '' this.posts = [] @@ -18,6 +19,10 @@ class SearchPage { this.pagination = null } + getMyAddress () { + return this.wallet?.walletInfo?.cashAddress || null + } + setQuery (q) { this.query = typeof q === 'string' ? q.trim() : '' return this @@ -28,7 +33,10 @@ class SearchPage { throw new Error('Search page requires a memo db client.') } - const data = await this.memoDb.search(this.query, { limit, offset }) + const opts = { limit, offset } + const viewer = this.getMyAddress() + if (viewer) opts.viewer = viewer + const data = await this.memoDb.search(this.query, opts) this.posts = data.posts || [] this.profiles = data.profiles || [] this.pagination = data.pagination || null diff --git a/psf-memo-client/src/services/topic-feed-page.js b/psf-memo-client/src/services/topic-feed-page.js index 892cbfd..7dba335 100644 --- a/psf-memo-client/src/services/topic-feed-page.js +++ b/psf-memo-client/src/services/topic-feed-page.js @@ -26,7 +26,9 @@ class TopicFeedPage { throw new Error('Topic feed page requires a topic room.') } - const data = await this.memoDb.getTopicPosts(this.room, { limit, offset }) + const opts = { limit, offset } + if (this.myAddr) opts.viewer = this.myAddr + const data = await this.memoDb.getTopicPosts(this.room, opts) this.posts = data.posts || [] this.pagination = data.pagination || null this.followState = await this._loadFollowState() diff --git a/psf-memo-client/test/unit/recent-feed-page.test.js b/psf-memo-client/test/unit/recent-feed-page.test.js index c710e38..3b29690 100644 --- a/psf-memo-client/test/unit/recent-feed-page.test.js +++ b/psf-memo-client/test/unit/recent-feed-page.test.js @@ -57,13 +57,35 @@ test('load forwards limit and offset to the memo db client', async () => { assert.deepEqual(calls, [{ limit: 10, offset: 20 }]) }) -test('load throws when no memo db client is provided', async () => { - const page = new RecentFeedPage({}) +test('load forwards the viewer address to the memo db client when a wallet is provided', async () => { + const calls = [] + const memoDb = { + async getRecentPosts (params) { + calls.push(params) + return { posts: [], pagination: {} } + } + } + const wallet = { walletInfo: { cashAddress: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' } } + const page = new RecentFeedPage({ memoDb, wallet }) - await assert.rejects( - () => page.load(), - /requires a memo db client/ - ) + await page.load({ limit: 10, offset: 20 }) + + assert.deepEqual(calls, [{ limit: 10, offset: 20, viewer: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' }]) +}) + +test('load omits viewer address when no wallet is provided', async () => { + const calls = [] + const memoDb = { + async getRecentPosts (params) { + calls.push(params) + return { posts: [], pagination: {} } + } + } + const page = new RecentFeedPage({ memoDb }) + + await page.load({ limit: 10, offset: 20 }) + + assert.deepEqual(calls, [{ limit: 10, offset: 20 }]) }) test('load defaults limit to 50 and offset to 0', async () => { @@ -80,3 +102,12 @@ test('load defaults limit to 50 and offset to 0', async () => { assert.deepEqual(calls, [{ limit: 50, offset: 0 }]) }) + +test('load throws when no memo db client is provided', async () => { + const page = new RecentFeedPage({}) + + await assert.rejects( + () => page.load(), + /requires a memo db client/ + ) +}) diff --git a/psf-memo-client/test/unit/search-page.test.js b/psf-memo-client/test/unit/search-page.test.js index 506963f..009e098 100644 --- a/psf-memo-client/test/unit/search-page.test.js +++ b/psf-memo-client/test/unit/search-page.test.js @@ -49,6 +49,30 @@ test('submit forwards query, limit and offset to the memo db client', async () = assert.deepEqual(calls, [{ q: 'alice', params: { limit: 10, offset: 20 } }]) }) +test('submit forwards the viewer address when a wallet is provided', async () => { + const calls = [] + const memoDb = { + async search (q, params) { + calls.push({ q, params }) + return { posts: [], profiles: [], pagination: {} } + } + } + const wallet = { walletInfo: { cashAddress: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' } } + const page = new SearchPage({ memoDb, wallet }) + + page.setQuery('alice') + await page.submit({ limit: 10, offset: 20 }) + + assert.deepEqual(calls, [{ + q: 'alice', + params: { + limit: 10, + offset: 20, + viewer: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + } + }]) +}) + test('submit defaults limit to 50 and offset to 0', async () => { const calls = [] const memoDb = { diff --git a/psf-memo-client/test/unit/topic-feed-page.test.js b/psf-memo-client/test/unit/topic-feed-page.test.js index 5f4557e..178ef01 100644 --- a/psf-memo-client/test/unit/topic-feed-page.test.js +++ b/psf-memo-client/test/unit/topic-feed-page.test.js @@ -71,6 +71,34 @@ test('load forwards limit and offset to the memo db client', async () => { assert.deepEqual(calls, [{ room: 'bitcoin', params: { limit: 10, offset: 20 } }]) }) +test('load forwards the viewer address when myAddr is provided', async () => { + const calls = [] + const memoDb = { + async getTopicPosts (room, params) { + calls.push({ room, params }) + return { posts: [], pagination: {} } + }, + async getTopicFollowState () { + return false + }, + async getTopicFollowers () { + return [] + } + } + const page = new TopicFeedPage({ memoDb, room: 'bitcoin', myAddr: MY_ADDRESS }) + + await page.load({ limit: 10, offset: 20 }) + + assert.deepEqual(calls, [{ + room: 'bitcoin', + params: { + limit: 10, + offset: 20, + viewer: MY_ADDRESS + } + }]) +}) + test('load defaults limit and offset to 50 and 0', async () => { const calls = [] const memoDb = { diff --git a/psf-memo-db/src/adapters/index.js b/psf-memo-db/src/adapters/index.js index 5c751ca..8007a80 100644 --- a/psf-memo-db/src/adapters/index.js +++ b/psf-memo-db/src/adapters/index.js @@ -35,7 +35,8 @@ class Adapters { postParentsDb: level.postParentsDb, postChildrenDb: level.postChildrenDb, likesDb: level.likesDb, - postLikesDb: level.postLikesDb + postLikesDb: level.postLikesDb, + muteQuery: this.muteQuery }) this.followQuery = new FollowQuery({ followsDb: level.followsDb @@ -45,7 +46,8 @@ class Adapters { }) this.topicQuery = new TopicQuery({ roomsDb: level.roomsDb, - postsDb: level.postsDb + postsDb: level.postsDb, + muteQuery: this.muteQuery }) this.pollQuery = new PollQuery({ pollsDb: level.pollsDb, @@ -56,7 +58,8 @@ class Adapters { postsDb: level.postsDb, postParentsDb: level.postParentsDb, namesDb: level.namesDb, - profilesDb: level.profilesDb + profilesDb: level.profilesDb, + muteQuery: this.muteQuery }) this.notificationsQuery = new NotificationsQuery({ postsDb: level.postsDb, @@ -64,7 +67,8 @@ class Adapters { postChildrenDb: level.postChildrenDb, likesDb: level.likesDb, postLikesDb: level.postLikesDb, - followsDb: level.followsDb + followsDb: level.followsDb, + muteQuery: this.muteQuery }) return true } diff --git a/psf-memo-db/src/adapters/notifications-query.js b/psf-memo-db/src/adapters/notifications-query.js index 85fd102..4ba43f2 100644 --- a/psf-memo-db/src/adapters/notifications-query.js +++ b/psf-memo-db/src/adapters/notifications-query.js @@ -18,6 +18,7 @@ class NotificationsQuery { likesDb, postLikesDb, followsDb, + muteQuery, bchjs = new BCHJS({ restURL: process.env.RESTURL || 'https://api.fullstack.cash/v5/' }) } = localConfig @@ -46,6 +47,7 @@ class NotificationsQuery { this.likesDb = likesDb this.postLikesDb = postLikesDb this.followsDb = followsDb + this.muteQuery = muteQuery || null this.bchjs = bchjs this.listNotifications = this.listNotifications.bind(this) @@ -57,7 +59,7 @@ class NotificationsQuery { } // Collect active follows where this address is the followee. - async _collectFollowNotifications (addr) { + async _collectFollowNotifications (addr, mutedAddrs) { const myHash160 = this.bchjs.Address.toHash160(addr) const notifications = [] @@ -67,6 +69,7 @@ class NotificationsQuery { const followerAddr = record.followerAddr || key.split(':')[0] if (followerAddr === addr) continue + if (mutedAddrs.has(followerAddr)) continue notifications.push({ type: 'follow', @@ -81,11 +84,12 @@ class NotificationsQuery { } // Collect likes on posts authored by this address, excluding self-likes. - async _collectLikeNotifications (addr) { + async _collectLikeNotifications (addr, mutedAddrs) { const notifications = [] for await (const [likeTxid, like] of this.likesDb.iterator()) { if (!like || like.addr === addr) continue + if (mutedAddrs.has(like.addr)) continue const post = await getPostOrNull(this.postsDb, like.postTxid) if (!post || post.addr !== addr) continue @@ -104,7 +108,7 @@ class NotificationsQuery { } // Collect replies to posts authored by this address, excluding own replies. - async _collectReplyNotifications (addr) { + async _collectReplyNotifications (addr, mutedAddrs) { const notifications = [] for await (const [, child] of this.postChildrenDb.iterator()) { @@ -114,6 +118,7 @@ class NotificationsQuery { const childPost = await this._replyNotificationChild(child, addr) if (!childPost) continue + if (mutedAddrs.has(childPost.addr)) continue notifications.push({ type: 'reply', @@ -153,9 +158,11 @@ class NotificationsQuery { // Return paginated notifications for addr, sorted newest-first. async listNotifications (addr, { limit, offset } = {}) { - const follows = await this._collectFollowNotifications(addr) - const likes = await this._collectLikeNotifications(addr) - const replies = await this._collectReplyNotifications(addr) + const mutedAddrs = await this._mutedAddrs(addr) + + const follows = await this._collectFollowNotifications(addr, mutedAddrs) + const likes = await this._collectLikeNotifications(addr, mutedAddrs) + const replies = await this._collectReplyNotifications(addr, mutedAddrs) const all = this._sortNotifications(follows.concat(likes).concat(replies)) const total = all.length @@ -163,6 +170,14 @@ class NotificationsQuery { return { notifications: page, total } } + + // Return a Set of addresses muted by addr, or an empty set when no mute + // query adapter is available. + async _mutedAddrs (addr) { + if (!this.muteQuery || !addr) return new Set() + const muted = await this.muteQuery.listMuted(addr) + return new Set(muted) + } } export default NotificationsQuery diff --git a/psf-memo-db/src/adapters/post-query.js b/psf-memo-db/src/adapters/post-query.js index cecad21..a0219cd 100644 --- a/psf-memo-db/src/adapters/post-query.js +++ b/psf-memo-db/src/adapters/post-query.js @@ -13,7 +13,7 @@ const HEIGHT_PAD = 12 class PostQuery { constructor (localConfig = {}) { - const { postsDb, postHeightsDb, addrPostHeightsDb, postParentsDb, postChildrenDb, likesDb, postLikesDb } = localConfig + const { postsDb, postHeightsDb, addrPostHeightsDb, postParentsDb, postChildrenDb, likesDb, postLikesDb, muteQuery } = localConfig if (!postsDb) { throw new Error('postsDb required when instantiating PostQuery adapter.') } @@ -42,6 +42,7 @@ class PostQuery { this.postChildrenDb = postChildrenDb this.likesDb = likesDb this.postLikesDb = postLikesDb + this.muteQuery = muteQuery || null this.scanRecentPostTxids = this.scanRecentPostTxids.bind(this) this.scanPostsByAddrTxids = this.scanPostsByAddrTxids.bind(this) @@ -199,11 +200,14 @@ class PostQuery { } } - async scanRecentPostTxids ({ limit, offset }) { + async scanRecentPostTxids ({ limit, offset, viewerAddr = null }) { + const mutedAddrs = await this._mutedAddrs(viewerAddr) const txids = [] let skipped = 0 for await (const txid of this.topLevelPostTxids({ reverse: true })) { + if (await this._isMutedPost(txid, mutedAddrs)) continue + if (skipped < offset) { skipped++ continue @@ -274,13 +278,15 @@ class PostQuery { return posts } - async countTopLevelPosts () { + async countTopLevelPosts (viewerAddr = null) { + const mutedAddrs = await this._mutedAddrs(viewerAddr) let count = 0 const iterator = this.topLevelPostTxids() for (;;) { - const { done } = await iterator.next() + const { done, value: txid } = await iterator.next() if (done) break + if (await this._isMutedPost(txid, mutedAddrs)) continue count++ } @@ -343,6 +349,23 @@ class PostQuery { if (!post) return false return followeeSet.has(post.addr) } + + // Return a Set of addresses muted by viewerAddr, or an empty set when no + // mute query adapter is available. + async _mutedAddrs (viewerAddr) { + if (!this.muteQuery || !viewerAddr) return new Set() + const muted = await this.muteQuery.listMuted(viewerAddr) + return new Set(muted) + } + + // True when a post is authored by an address in mutedAddrs. Missing posts + // are treated as not muted. + async _isMutedPost (txid, mutedAddrs) { + if (mutedAddrs.size === 0) return false + const post = await this.getPostOrNull(txid) + if (!post) return false + return mutedAddrs.has(post.addr) + } } export default PostQuery diff --git a/psf-memo-db/src/adapters/search-query.js b/psf-memo-db/src/adapters/search-query.js index e6497d0..e2d279e 100644 --- a/psf-memo-db/src/adapters/search-query.js +++ b/psf-memo-db/src/adapters/search-query.js @@ -12,7 +12,7 @@ import { loadReplyTxids } from './lib/load-reply-txids.js' class SearchQuery { constructor (localConfig = {}) { - const { postsDb, postParentsDb, namesDb, profilesDb } = localConfig + const { postsDb, postParentsDb, namesDb, profilesDb, muteQuery } = localConfig if (!postsDb) { throw new Error('postsDb required when instantiating SearchQuery adapter.') } @@ -29,21 +29,24 @@ class SearchQuery { this.postParentsDb = postParentsDb this.namesDb = namesDb this.profilesDb = profilesDb + this.muteQuery = muteQuery || null this.searchPosts = this.searchPosts.bind(this) this.searchProfiles = this.searchProfiles.bind(this) this.profileMatches = this.profileMatches.bind(this) } - async searchPosts (query) { + async searchPosts (query, { viewerAddr = null } = {}) { const normalized = normalizeQuery(query) if (normalized.length === 0) return [] const replyTxids = await loadReplyTxids(this.postParentsDb) + const mutedAddrs = await this._mutedAddrs(viewerAddr) const matches = [] for await (const [txid, post] of this.postsDb.iterator()) { if (this.isMatchingPost(txid, post, replyTxids, normalized)) { + if (mutedAddrs.has(post.addr)) continue matches.push({ txid, addr: post.addr, @@ -133,6 +136,14 @@ class SearchQuery { blockHeight } } + + // Return a Set of addresses muted by viewerAddr, or an empty set when no + // mute query adapter is available. + async _mutedAddrs (viewerAddr) { + if (!this.muteQuery || !viewerAddr) return new Set() + const muted = await this.muteQuery.listMuted(viewerAddr) + return new Set(muted) + } } export default SearchQuery diff --git a/psf-memo-db/src/adapters/topic-query.js b/psf-memo-db/src/adapters/topic-query.js index 43a4de5..a8ef5cb 100644 --- a/psf-memo-db/src/adapters/topic-query.js +++ b/psf-memo-db/src/adapters/topic-query.js @@ -12,7 +12,7 @@ class TopicQuery { constructor (localConfig = {}) { - const { roomsDb, postsDb } = localConfig + const { roomsDb, postsDb, muteQuery } = localConfig if (!roomsDb) { throw new Error('roomsDb required when instantiating TopicQuery adapter.') } @@ -21,6 +21,7 @@ class TopicQuery { } this.roomsDb = roomsDb this.postsDb = postsDb + this.muteQuery = muteQuery || null this.listTopics = this.listTopics.bind(this) this.getTopicPostTxids = this.getTopicPostTxids.bind(this) @@ -72,7 +73,8 @@ class TopicQuery { .map(({ room, postCount }) => ({ room, postCount })) } - async getTopicPostTxids (room, { limit, offset }) { + async getTopicPostTxids (room, { limit, offset, viewerAddr = null }) { + const mutedAddrs = await this._mutedAddrs(viewerAddr) const start = `${room}:` const end = `${room}:\uffff` const entries = [] @@ -80,6 +82,7 @@ class TopicQuery { for await (const [key, value] of this.roomsDb.iterator({ gte: start, lte: end })) { if (value?.type !== 'post') continue const txid = (value && typeof value.txid === 'string') ? value.txid : this.txidFromKey(key) + if (await this._isMutedPost(txid, mutedAddrs)) continue const blockHeight = value?.blockHeight ?? 0 entries.push({ txid, blockHeight }) } @@ -92,6 +95,23 @@ class TopicQuery { return { txids, total } } + // Return a Set of addresses muted by viewerAddr, or an empty set when no + // mute query adapter is available. + async _mutedAddrs (viewerAddr) { + if (!this.muteQuery || !viewerAddr) return new Set() + const muted = await this.muteQuery.listMuted(viewerAddr) + return new Set(muted) + } + + // True when a post is authored by an address in mutedAddrs. Missing posts + // are treated as not muted. + async _isMutedPost (txid, mutedAddrs) { + if (mutedAddrs.size === 0) return false + const post = await this.postsDb.get(txid).catch(() => null) + if (!post) return false + return mutedAddrs.has(post.addr) + } + // Return true when addr has an active follow record for room. async isFollowingRoom (addr, room) { const key = `${room}:${addr}` diff --git a/psf-memo-db/src/controllers/rest-api/posts/controller.js b/psf-memo-db/src/controllers/rest-api/posts/controller.js index 1a98328..adeb23b 100644 --- a/psf-memo-db/src/controllers/rest-api/posts/controller.js +++ b/psf-memo-db/src/controllers/rest-api/posts/controller.js @@ -62,6 +62,7 @@ class PostsRESTControllerLib { * * @apiQuery {Number} [limit=100] Page size (max 100) * @apiQuery {Number} [offset=0] Number of posts to skip after sorting + * @apiQuery {String} [viewer] Viewer cash address; posts from addresses the viewer mutes are excluded * * @apiExample Example usage: * curl -X GET "localhost:5021/posts/recent?limit=50&offset=0" @@ -80,8 +81,10 @@ class PostsRESTControllerLib { * @apiSuccess {Boolean} pagination.hasMore True if more pages exist */ async getRecentPosts (ctx) { - const { limit, offset } = ctx.query - await this.runUseCase(ctx, () => this.useCases.listRecentPosts.execute({ limit, offset })) + const { limit, offset, viewer } = ctx.query + const args = { limit, offset } + if (viewer) args.viewerAddr = viewer + await this.runUseCase(ctx, () => this.useCases.listRecentPosts.execute(args)) } /** diff --git a/psf-memo-db/src/controllers/rest-api/search/controller.js b/psf-memo-db/src/controllers/rest-api/search/controller.js index 85f22bb..e34c37d 100644 --- a/psf-memo-db/src/controllers/rest-api/search/controller.js +++ b/psf-memo-db/src/controllers/rest-api/search/controller.js @@ -36,6 +36,7 @@ class SearchRESTControllerLib { * @apiQuery {String} q Search query * @apiQuery {Number} [limit=100] Page size (max 100) * @apiQuery {Number} [offset=0] Number of results to skip after sorting + * @apiQuery {String} [viewer] Viewer cash address; posts from addresses the viewer mutes are excluded * * @apiExample Example usage: * curl -X GET "localhost:5021/search?q=hello&limit=50&offset=0" @@ -46,8 +47,10 @@ class SearchRESTControllerLib { */ async search (ctx) { try { - const { q, limit, offset } = ctx.query - ctx.body = await this.useCases.searchAll.execute({ q, limit, offset }) + const { q, limit, offset, viewer } = ctx.query + const args = { q, limit, offset } + if (viewer) args.viewerAddr = viewer + ctx.body = await this.useCases.searchAll.execute(args) } catch (err) { this.handleError(ctx, err) } diff --git a/psf-memo-db/src/controllers/rest-api/topics/controller.js b/psf-memo-db/src/controllers/rest-api/topics/controller.js index a82d796..0770cc2 100644 --- a/psf-memo-db/src/controllers/rest-api/topics/controller.js +++ b/psf-memo-db/src/controllers/rest-api/topics/controller.js @@ -61,6 +61,7 @@ class TopicsRESTControllerLib { * @apiParam {String} room Topic name * @apiQuery {Number} [limit=100] Page size (max 100) * @apiQuery {Number} [offset=0] Number of posts to skip after sorting + * @apiQuery {String} [viewer] Viewer cash address; posts from addresses the viewer mutes are excluded * * @apiExample Example usage: * curl -X GET "localhost:5021/topics/bitcoin/posts?limit=50&offset=0" @@ -77,8 +78,10 @@ class TopicsRESTControllerLib { async getTopicPosts (ctx) { try { const { room } = ctx.params - const { limit, offset } = ctx.query - ctx.body = await this.useCases.listTopicPosts.execute({ room, limit, offset }) + const { limit, offset, viewer } = ctx.query + const args = { room, limit, offset } + if (viewer) args.viewerAddr = viewer + ctx.body = await this.useCases.listTopicPosts.execute(args) } catch (err) { this.handleError(ctx, err) } diff --git a/psf-memo-db/src/use-cases/list-recent-posts.js b/psf-memo-db/src/use-cases/list-recent-posts.js index 53eb9ab..dfc8400 100644 --- a/psf-memo-db/src/use-cases/list-recent-posts.js +++ b/psf-memo-db/src/use-cases/list-recent-posts.js @@ -14,13 +14,16 @@ class ListRecentPosts extends ListUseCase { async execute (inObj = {}) { const limit = parseLimit(inObj.limit) const offset = parseOffset(inObj.offset) + const viewerAddr = inObj.viewerAddr || inObj.viewer || null - const txids = await this.adapters.postQuery.scanRecentPostTxids({ limit, offset }) + const scanArgs = { limit, offset } + if (viewerAddr) scanArgs.viewerAddr = viewerAddr + const txids = await this.adapters.postQuery.scanRecentPostTxids(scanArgs) const [posts, replyCounts, likeCounts, total] = await Promise.all([ this.adapters.postQuery.loadPostsByTxids(txids), this.adapters.postQuery.buildReplyCountMap(), this.adapters.postQuery.countLikesForTxids(txids), - this.adapters.postQuery.countTopLevelPosts() + this.adapters.postQuery.countTopLevelPosts(viewerAddr) ]) return assemblePostPage({ posts, replyCounts, likeCounts, total, limit, offset }) diff --git a/psf-memo-db/src/use-cases/list-topic-posts.js b/psf-memo-db/src/use-cases/list-topic-posts.js index 7b60035..e775ba7 100644 --- a/psf-memo-db/src/use-cases/list-topic-posts.js +++ b/psf-memo-db/src/use-cases/list-topic-posts.js @@ -25,8 +25,9 @@ class ListTopicPosts { const room = parseRequiredString(inObj.room, 'room') const limit = parseLimit(inObj.limit) const offset = parseOffset(inObj.offset) + const viewerAddr = inObj.viewerAddr || inObj.viewer || null - const { txids, total } = await this.adapters.topicQuery.getTopicPostTxids(room, { limit, offset }) + const { txids, total } = await this.adapters.topicQuery.getTopicPostTxids(room, { limit, offset, viewerAddr }) const [posts, replyCounts, likeCounts] = await Promise.all([ this.adapters.postQuery.loadPostsByTxids(txids), this.adapters.postQuery.countRepliesForTxids(txids), diff --git a/psf-memo-db/src/use-cases/search-all.js b/psf-memo-db/src/use-cases/search-all.js index e551dba..5c97b16 100644 --- a/psf-memo-db/src/use-cases/search-all.js +++ b/psf-memo-db/src/use-cases/search-all.js @@ -19,13 +19,14 @@ class SearchAll extends ListUseCase { const q = normalizeQuery(inObj.q) const limit = parseLimit(inObj.limit) const offset = parseOffset(inObj.offset) + const viewerAddr = inObj.viewerAddr || inObj.viewer || null if (q.length === 0) { return this.emptyResult(limit, offset) } const [allPosts, allProfiles] = await Promise.all([ - this.adapters.searchQuery.searchPosts(q), + this.adapters.searchQuery.searchPosts(q, { viewerAddr }), this.adapters.searchQuery.searchProfiles(q) ]) diff --git a/psf-memo-db/test/unit/adapters/notifications-query.unit.js b/psf-memo-db/test/unit/adapters/notifications-query.unit.js index 7977395..ac0b479 100644 --- a/psf-memo-db/test/unit/adapters/notifications-query.unit.js +++ b/psf-memo-db/test/unit/adapters/notifications-query.unit.js @@ -113,6 +113,42 @@ describe('#NotificationsQuery', () => { } }) + it('should exclude notifications from muted addresses when a mute query is provided', async () => { + const myPostTxid = 'a'.repeat(64) + const replyTxid = 'b'.repeat(64) + const likeTxid = 'c'.repeat(64) + + postChildrenDb.iterator.returns(makeIterator([ + [`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 100 }] + ])) + likesDb.iterator.returns(makeIterator([ + [likeTxid, { addr: THEIR_ADDR, postTxid: myPostTxid, blockHeight: 300 }] + ])) + followsDb.iterator.returns(makeIterator([])) + + postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' }) + postsDb.get.withArgs(replyTxid).resolves({ addr: THEIR_ADDR, text: 'reply' }) + + const muteQuery = { + listMuted: sandbox.stub().resolves([THEIR_ADDR]) + } + uut = new NotificationsQuery({ + postsDb, + postParentsDb: {}, + postChildrenDb, + likesDb, + postLikesDb: {}, + followsDb, + muteQuery, + bchjs + }) + + const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 }) + + assert.equal(result.total, 0) + assert.isTrue(muteQuery.listMuted.calledOnceWith(MY_ADDR)) + }) + it('should return null from getPostOrNull when the post is not found', async () => { const missingTxid = 'c'.repeat(64) const notFound = new Error('not found') diff --git a/psf-memo-db/test/unit/adapters/post-query.unit.js b/psf-memo-db/test/unit/adapters/post-query.unit.js index 00d0b75..5e3a15b 100644 --- a/psf-memo-db/test/unit/adapters/post-query.unit.js +++ b/psf-memo-db/test/unit/adapters/post-query.unit.js @@ -606,4 +606,75 @@ describe('#PostQuery', () => { assert.equal(result.get('tx3'), 1) }) }) + + describe('#mute filtering', () => { + it('should exclude posts from muted addresses in recent posts', async () => { + async function * mockHeights () { + yield ['000000600300:post-300-muted', { txid: 'post-300-muted' }] + yield ['000000600200:post-200', { txid: 'post-200' }] + yield ['000000600100:post-100-muted', { txid: 'post-100-muted' }] + } + postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights()) + postsDb.get.callsFake(async (txid) => { + if (txid === 'post-300-muted') return { addr: 'muted-addr', text: 'x', seen: 1, blockHeight: 600300 } + if (txid === 'post-200') return { addr: 'other-addr', text: 'x', seen: 2, blockHeight: 600200 } + if (txid === 'post-100-muted') return { addr: 'muted-addr', text: 'x', seen: 3, blockHeight: 600100 } + const err = new Error('not found') + err.notFound = true + throw err + }) + + const muteQuery = { + listMuted: sandbox.stub().resolves(['muted-addr']) + } + uut = new PostQuery({ + postsDb, + postHeightsDb, + addrPostHeightsDb, + postParentsDb, + postChildrenDb, + likesDb, + postLikesDb, + muteQuery + }) + + const result = await uut.scanRecentPostTxids({ limit: 2, offset: 0, viewerAddr: 'viewer-addr' }) + + assert.deepEqual(result, ['post-200']) + assert.isTrue(muteQuery.listMuted.calledOnceWith('viewer-addr')) + }) + + it('should count top-level posts excluding muted addresses', async () => { + async function * mockHeights () { + yield ['000000600200:post-200-muted', { txid: 'post-200-muted' }] + yield ['000000600100:post-100', { txid: 'post-100' }] + } + postHeightsDb.iterator.withArgs({ reverse: false }).returns(mockHeights()) + postsDb.get.callsFake(async (txid) => { + if (txid === 'post-200-muted') return { addr: 'muted-addr', text: 'x', seen: 1, blockHeight: 600200 } + if (txid === 'post-100') return { addr: 'other-addr', text: 'x', seen: 2, blockHeight: 600100 } + const err = new Error('not found') + err.notFound = true + throw err + }) + + const muteQuery = { + listMuted: sandbox.stub().resolves(['muted-addr']) + } + uut = new PostQuery({ + postsDb, + postHeightsDb, + addrPostHeightsDb, + postParentsDb, + postChildrenDb, + likesDb, + postLikesDb, + muteQuery + }) + + const result = await uut.countTopLevelPosts('viewer-addr') + + assert.equal(result, 1) + }) + }) }) diff --git a/psf-memo-db/test/unit/adapters/search-query.unit.js b/psf-memo-db/test/unit/adapters/search-query.unit.js index b2b6625..ee09eb8 100644 --- a/psf-memo-db/test/unit/adapters/search-query.unit.js +++ b/psf-memo-db/test/unit/adapters/search-query.unit.js @@ -123,6 +123,23 @@ describe('#SearchQuery', () => { assert.equal(result[0].text, 'bitcoin cash enthusiast') }) + it('should exclude posts from muted addresses when a viewer is provided', async () => { + postsDb.iterator = () => makeIterator([ + ['tx1', { addr: 'muted-addr', text: 'hello world', seen: 100, blockHeight: 600100 }], + ['tx2', { addr: 'other-addr', text: 'hello again', seen: 200, blockHeight: 600200 }] + ])() + postParentsDb.iterator = () => makeIterator([])() + + const muteQuery = { + listMuted: async () => ['muted-addr'] + } + const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb, muteQuery }) + const result = await uut.searchPosts('hello', { viewerAddr: 'viewer-addr' }) + + assert.equal(result.length, 1) + assert.equal(result[0].txid, 'tx2') + }) + it('should return no posts when query is empty', async () => { postsDb.iterator = () => makeIterator([ ['tx1', { addr: 'addr1', text: 'hello world', seen: 100, blockHeight: 600100 }] diff --git a/psf-memo-db/test/unit/adapters/topic-query.unit.js b/psf-memo-db/test/unit/adapters/topic-query.unit.js index b513187..4d6fe69 100644 --- a/psf-memo-db/test/unit/adapters/topic-query.unit.js +++ b/psf-memo-db/test/unit/adapters/topic-query.unit.js @@ -239,19 +239,32 @@ describe('#TopicQuery', () => { assert.equal(result.total, 1) }) - it('should treat a post without a block height as height 0', async () => { + it('should exclude posts from muted addresses when a viewer is provided', async () => { async function * mockRooms () { - yield ['bitcoin:post-a', { room: 'bitcoin', txid: 'post-a', type: 'post', blockHeight: 0 }] - yield ['bitcoin:post-b', { room: 'bitcoin', txid: 'post-b', type: 'post' }] + yield ['bitcoin:post-300', { room: 'bitcoin', txid: 'post-300', type: 'post', blockHeight: 300 }] + yield ['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 200 }] } roomsDb.iterator .withArgs(sinon.match({ gte: 'bitcoin:', lte: 'bitcoin:\uffff' })) .returns(mockRooms()) + postsDb.get.callsFake(async (txid) => { + if (txid === 'post-300') return { addr: 'muted-addr', text: 'x', blockHeight: 300 } + if (txid === 'post-200') return { addr: 'other-addr', text: 'x', blockHeight: 200 } + const err = new Error('not found') + err.notFound = true + throw err + }) - const result = await uut.getTopicPostTxids('bitcoin', { limit: 100, offset: 0 }) + const muteQuery = { + listMuted: sandbox.stub().resolves(['muted-addr']) + } + uut = new TopicQuery({ roomsDb, postsDb, muteQuery }) - assert.deepEqual(result.txids, ['post-a', 'post-b']) - assert.equal(result.total, 2) + const result = await uut.getTopicPostTxids('bitcoin', { limit: 100, offset: 0, viewerAddr: 'viewer-addr' }) + + assert.deepEqual(result.txids, ['post-200']) + assert.equal(result.total, 1) + assert.isTrue(muteQuery.listMuted.calledOnceWith('viewer-addr')) }) })