Implement mute feed filtering across recent, topic, search, and notifications feeds

By coder.
This commit is contained in:
Chris Troutner
2026-09-04 12:23:00 -07:00
parent c8ef2ddd95
commit 448bddc31f
25 changed files with 510 additions and 61 deletions
+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 (.+)$/,
@@ -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
@@ -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'))
})
})