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