Merge mute-feed-filtering coder changes

This commit is contained in:
Chris Troutner
2026-09-04 12:23:32 -07:00
33 changed files with 706 additions and 69 deletions
@@ -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.
+114 -10
View File
@@ -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 (.+)$/,
@@ -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
@@ -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 <muted>
Given the psf-memo-db API serves a post with txid <txid_a> authored by the address <muted> with text <text_a>
Given the psf-memo-db API serves a post with txid <txid_b> authored by the address <other> with text <text_b>
When I open the recent posts feed
Then the recent feed shows the post with text <text_b>
And the recent feed does not show the post with text <text_a>
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 <muted>
Given the psf-memo-db API serves a post with txid <txid_a> in the topic "bitcoin" authored by the address <muted> with text <text_a>
Given the psf-memo-db API serves a post with txid <txid_b> in the topic "bitcoin" authored by the address <other> with text <text_b>
When I open the topic feed for "bitcoin"
Then the topic feed shows the post with text <text_b>
And the topic feed does not show the post with text <text_a>
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 <muted>
Given the psf-memo-db API has a post with the text <text_a> authored by the address <muted>
Given the psf-memo-db API has a post with the text <text_b> authored by the address <other>
When I open the Search page
And I submit a search for <query>
Then the search results include a post with the text <text_b>
And the search results do not include a post with the text <text_a>
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 <muted>
Given the psf-memo-db API serves a post with txid <my_post> authored by my wallet address with text "hello from me"
Given the psf-memo-db API serves a reply with txid <reply_a> to the post with txid <my_post> by the address <muted> with text <text_a>
Given the psf-memo-db API serves a reply with txid <reply_b> to the post with txid <my_post> by the address <other> with text <text_b>
When I open the Notifications page
Then the notifications include a reply notification from the address <other> with text <text_b>
And the notifications do not include a reply notification from the address <muted>
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"
@@ -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 || []
@@ -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 || [])
+12 -6
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
@@ -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
@@ -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) {
+9 -1
View File
@@ -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
@@ -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()
@@ -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/
)
})
@@ -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 = {
@@ -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 = {
+8 -4
View File
@@ -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
}
@@ -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
+27 -4
View File
@@ -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
+13 -2
View File
@@ -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
+22 -2
View File
@@ -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}`
@@ -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))
}
/**
@@ -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)
}
@@ -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)
}
@@ -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 })
@@ -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),
+2 -1
View File
@@ -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)
])
@@ -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')
@@ -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)
})
})
})
@@ -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 }]
@@ -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'))
})
})
+17 -5
View File
@@ -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**.
+8
View File
@@ -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,