mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Merge mute-feed-filtering coder changes
This commit is contained in:
@@ -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.
|
||||
@@ -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 || [])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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**.
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user