From 448bddc31f35c627e7bd8d2e8f1debba38bc4259 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 4 Sep 2026 12:23:00 -0700 Subject: [PATCH] Implement mute feed filtering across recent, topic, search, and notifications feeds By coder. --- psf-memo-client/acceptance/lib/handlers.js | 124 ++++++++++++++++-- .../src/components/app-body/posts/index.js | 4 +- .../src/components/app-body/search/index.js | 4 +- psf-memo-client/src/services/memo-db.js | 18 ++- .../src/services/recent-feed-page.js | 20 +++ psf-memo-client/src/services/search-page.js | 10 +- .../src/services/topic-feed-page.js | 4 +- .../test/unit/recent-feed-page.test.js | 43 +++++- psf-memo-client/test/unit/search-page.test.js | 24 ++++ .../test/unit/topic-feed-page.test.js | 28 ++++ psf-memo-db/src/adapters/index.js | 12 +- .../src/adapters/notifications-query.js | 27 +++- psf-memo-db/src/adapters/post-query.js | 31 ++++- psf-memo-db/src/adapters/search-query.js | 15 ++- psf-memo-db/src/adapters/topic-query.js | 24 +++- .../controllers/rest-api/posts/controller.js | 7 +- .../controllers/rest-api/search/controller.js | 7 +- .../controllers/rest-api/topics/controller.js | 7 +- .../src/use-cases/list-recent-posts.js | 7 +- psf-memo-db/src/use-cases/list-topic-posts.js | 3 +- psf-memo-db/src/use-cases/search-all.js | 3 +- .../unit/adapters/notifications-query.unit.js | 36 +++++ .../test/unit/adapters/post-query.unit.js | 71 ++++++++++ .../test/unit/adapters/search-query.unit.js | 17 +++ .../test/unit/adapters/topic-query.unit.js | 25 +++- 25 files changed, 510 insertions(+), 61 deletions(-) diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index c8fdbf2..e38bc17 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -287,6 +287,9 @@ function makeMemoDb () { setMuteState (muterAddr, muteeAddr, muted) { muteState[`${muterAddr}:${muteeAddr}`] = muted }, + isMuted (muterAddr, muteeAddr) { + return muteState[`${muterAddr}:${muteeAddr}`] === true + }, setTopicFollowState (addr, room, following) { if (!topicFollow.has(room)) topicFollow.set(room, new Map()) topicFollow.get(room).set(addr, following) @@ -301,14 +304,17 @@ function makeMemoDb () { } return addrs }, - async search (q, { limit = 50, offset = 0 } = {}) { + async search (q, { limit = 50, offset = 0, viewer = null } = {}) { const normalized = String(q).trim().toLowerCase() if (normalized.length === 0) { return { posts: [], profiles: [], pagination: { total: 0, hasMore: false } } } - const matchedPosts = searchPosts.filter((p) => + let matchedPosts = searchPosts.filter((p) => typeof p.text === 'string' && p.text.toLowerCase().includes(normalized) ) + if (viewer) { + matchedPosts = matchedPosts.filter((p) => !this.isMuted(viewer, p.addr)) + } const matchedProfiles = searchProfiles.filter((p) => (typeof p.name === 'string' && p.name.toLowerCase().includes(normalized)) || (typeof p.text === 'string' && p.text.toLowerCase().includes(normalized)) @@ -321,9 +327,13 @@ function makeMemoDb () { pagination: { total, limit, offset, hasMore: offset + page.length < total } } }, - async getRecentPosts ({ limit = 50, offset = 0 } = {}) { - const page = posts.slice(offset, offset + limit) - return { posts: page, pagination: { total: posts.length, limit, offset, hasMore: offset + page.length < posts.length } } + async getRecentPosts ({ limit = 50, offset = 0, viewer = null } = {}) { + let filtered = posts + if (viewer) { + filtered = posts.filter((p) => !this.isMuted(viewer, p.addr)) + } + const page = filtered.slice(offset, offset + limit) + return { posts: page, pagination: { total: filtered.length, limit, offset, hasMore: offset + page.length < filtered.length } } }, async getPostsByAddr (addr, { limit = 50, offset = 0 } = {}) { const filtered = posts.filter((p) => p.addr === addr) @@ -347,8 +357,11 @@ function makeMemoDb () { list.sort((a, b) => a.room.localeCompare(b.room)) return { topics: list } }, - async getTopicPosts (room, { limit = 50, offset = 0 } = {}) { - const all = topicPosts[room] || [] + async getTopicPosts (room, { limit = 50, offset = 0, viewer = null } = {}) { + let all = topicPosts[room] || [] + if (viewer) { + all = all.filter((p) => !this.isMuted(viewer, p.addr)) + } const page = all.slice(offset, offset + limit) return { posts: page, pagination: { total: all.length, limit, offset, hasMore: offset + page.length < all.length } } }, @@ -359,6 +372,7 @@ function makeMemoDb () { const parent = posts.find((p) => p.txid === reply.parentTxid) if (!parent || parent.addr !== addr) continue if (reply.addr === addr) continue + if (this.isMuted(addr, reply.addr)) continue notifications.push({ type: 'reply', txid: reply.txid, @@ -373,6 +387,7 @@ function makeMemoDb () { const post = posts.find((p) => p.txid === like.postTxid) if (!post || post.addr !== addr) continue if (like.addr === addr) continue + if (this.isMuted(addr, like.addr)) continue notifications.push({ type: 'like', txid: like.txid, @@ -384,6 +399,7 @@ function makeMemoDb () { for (const follow of (followers.get(addr) || [])) { if (follow.followerAddr === addr) continue + if (this.isMuted(addr, follow.followerAddr)) continue notifications.push({ type: 'follow', txid: follow.txid, @@ -453,7 +469,7 @@ function createWorld () { } // Read-only page controllers backed by the fake psf-memo-db API. - world.recentFeedPage = new RecentFeedPage({ memoDb }) + world.recentFeedPage = new RecentFeedPage({ memoDb, wallet }) world.followingFeedPage = new FollowingFeedPage({ memoDb, wallet }) world.notificationsPage = new NotificationsPage({ memoDb, wallet }) world.profilePage = new ProfilePage({ memoDb }) @@ -464,6 +480,7 @@ function createWorld () { }) world.searchPage = new SearchPage({ memoDb, + wallet, navigate: (path) => { world.currentPath = path } }) world.recentProfilesPage = new RecentProfilesPage({ memoDb }) @@ -1435,6 +1452,28 @@ const handlers = [ world.renderedFeed = world.recentFeedPage.posts.map((post) => renderPostText(post.text)) } }, + { + name: 'recent feed shows post text', + pattern: /^the recent feed shows the post with text (.+)$/, + run (m, example, world) { + const expected = resolveText(m[1], example) + const found = world.recentFeedPage.posts.find((p) => p.text === expected) + if (!found) { + throw new Error(`Recent feed does not show a post with text "${expected}".`) + } + } + }, + { + name: 'recent feed does not show post text', + pattern: /^the recent feed does not show the post with text (.+)$/, + run (m, example, world) { + const expected = resolveText(m[1], example) + const found = world.recentFeedPage.posts.find((p) => p.text === expected) + if (found) { + throw new Error(`Recent feed unexpectedly shows a post with text "${expected}".`) + } + } + }, { name: 'open profile page for author', pattern: /^I open the profile page for the author of the post with txid (.+)$/, @@ -1661,6 +1700,15 @@ const handlers = [ world.memoDb.setMuteState(myAddr, addr, true) } }, + { + name: 'API reports I unmute address', + pattern: /^the psf-memo-db API reports that I unmute the address (.+)$/, + run (m, example, world) { + const addr = resolveParam(m[1], example) + const myAddr = world.wallet.walletInfo.cashAddress + world.memoDb.setMuteState(myAddr, addr, false) + } + }, { name: 'profile page shows Mute button', pattern: /^the profile page shows a Mute button$/, @@ -1798,12 +1846,12 @@ const handlers = [ }, { name: 'API serves post in topic with address and text', - pattern: /^the psf-memo-db API serves a post with txid (.+) in the topic "([^"]+)" authored by the address (.+) with text "(.+)"$/, + pattern: /^the psf-memo-db API serves a post with txid (.+) in the topic "([^"]+)" authored by the address (.+) with text (.+)$/, run (m, example, world) { const txid = resolveParam(m[1], example) const room = m[2] const addr = resolveParam(m[3], example) - const text = m[4] + const text = resolveText(m[4], example) world.memoDb.addTopicPost(room, { txid, addr, text, blockHeight: 100 }) } }, @@ -1904,6 +1952,28 @@ const handlers = [ } } }, + { + name: 'topic feed shows post with text', + pattern: /^the topic feed shows the post with text (.+)$/, + run (m, example, world) { + const expected = resolveText(m[1], example) + const found = world.topicFeedPage.posts.find((p) => p.text === expected) + if (!found) { + throw new Error(`Topic feed does not show a post with text "${expected}".`) + } + } + }, + { + name: 'topic feed does not show post with text', + pattern: /^the topic feed does not show the post with text (.+)$/, + run (m, example, world) { + const expected = resolveText(m[1], example) + const found = world.topicFeedPage.posts.find((p) => p.text === expected) + if (found) { + throw new Error(`Topic feed unexpectedly shows a post with text "${expected}".`) + } + } + }, { name: 'topic feed shows empty message', pattern: /^the feed shows a message that there are no posts$/, @@ -2296,6 +2366,16 @@ const handlers = [ }) } }, + { + name: 'API has search post with text and address', + pattern: /^the psf-memo-db API has a post with the text (.+) authored by the address (.+)$/, + run (m, example, world) { + const text = resolveText(m[1], example) + const addr = resolveParam(m[2], example) + const txid = require('crypto').createHash('sha256').update(`${text}:${addr}`).digest('hex') + world.memoDb.addSearchPost({ txid, addr, text, blockHeight: 100 }) + } + }, { name: 'API has search profile', pattern: /^the psf-memo-db API has a profile named "(.+)" with the bio "(.+)"$/, @@ -2333,6 +2413,17 @@ const handlers = [ } } }, + { + name: 'search results do not include post text', + pattern: /^the search results do not include a post with the text (.+)$/, + run (m, example, world) { + const expected = resolveText(m[1], example) + const found = world.searchPage.posts.find((p) => p.text === expected) + if (found) { + throw new Error(`Search results unexpectedly include a post with text "${expected}".`) + } + } + }, { name: 'search results include profile name', pattern: /^the search results include a profile named (.+)$/, @@ -2596,6 +2687,19 @@ const handlers = [ } } }, + { + name: 'notifications do not include reply notification', + pattern: /^the notifications do not include a reply notification from the address (.+)$/, + run (m, example, world) { + const expectedAddr = resolveParam(m[1], example) + const found = world.notificationsPage.notifications.find((n) => + n.type === 'reply' && n.addr === expectedAddr + ) + if (found) { + throw new Error(`Notifications unexpectedly include a reply from ${expectedAddr}.`) + } + } + }, { name: 'notifications include like notification', pattern: /^the notifications include a like notification from the address (.+)$/, diff --git a/psf-memo-client/src/components/app-body/posts/index.js b/psf-memo-client/src/components/app-body/posts/index.js index 0123287..592fe1a 100644 --- a/psf-memo-client/src/components/app-body/posts/index.js +++ b/psf-memo-client/src/components/app-body/posts/index.js @@ -48,9 +48,11 @@ function RecentPosts (props) { try { const memoDb = new MemoDb() + const viewer = appData?.wallet?.walletInfo?.cashAddress const data = await memoDb.getRecentPosts({ limit: PAGE_SIZE, - offset + offset, + viewer }) const loadedPosts = data.posts || [] diff --git a/psf-memo-client/src/components/app-body/search/index.js b/psf-memo-client/src/components/app-body/search/index.js index 3af6089..47e87cd 100644 --- a/psf-memo-client/src/components/app-body/search/index.js +++ b/psf-memo-client/src/components/app-body/search/index.js @@ -79,7 +79,7 @@ function Search (props) { try { const memoDb = new MemoDb() - const page = new SearchPage({ memoDb }) + const page = new SearchPage({ memoDb, wallet: props.appData?.wallet }) page.setQuery(query) const result = await page.submit({ limit: PAGE_SIZE, offset: 0 }) setPosts(result.posts || []) @@ -104,7 +104,7 @@ function Search (props) { try { const memoDb = new MemoDb() - const page = new SearchPage({ memoDb }) + const page = new SearchPage({ memoDb, wallet: props.appData?.wallet }) page.setQuery(query) const result = await page.submit({ limit: PAGE_SIZE, offset: nextOffset }) setPosts(result.posts || []) diff --git a/psf-memo-client/src/services/memo-db.js b/psf-memo-client/src/services/memo-db.js index 4f77965..93c7f92 100644 --- a/psf-memo-client/src/services/memo-db.js +++ b/psf-memo-client/src/services/memo-db.js @@ -14,8 +14,10 @@ class MemoDb { return this.getRecent('/profile/recent', 'getRecentProfiles', { limit, offset }) } - async getRecentPosts ({ limit = 50, offset = 0 } = {}) { - return this.getRecent('/posts/recent', 'getRecentPosts', { limit, offset }) + async getRecentPosts ({ limit = 50, offset = 0, viewer = null } = {}) { + const params = { limit, offset } + if (viewer) params.viewer = viewer + return this.getRecent('/posts/recent', 'getRecentPosts', params) } async getProfile (addr) { @@ -46,8 +48,10 @@ class MemoDb { return this.getRecent('/topics', 'getTopics', {}) } - async getTopicPosts (room, opts = {}) { - return this.getPage(`/topics/${encodeURIComponent(room)}/posts`, 'getTopicPosts', opts) + async getTopicPosts (room, { limit = 50, offset = 0, viewer = null } = {}) { + const params = { limit, offset } + if (viewer) params.viewer = viewer + return this.getPage(`/topics/${encodeURIComponent(room)}/posts`, 'getTopicPosts', params) } async getTopicFollowState (room, addr) { @@ -58,10 +62,12 @@ class MemoDb { return this._getList(`/topics/${encodeURIComponent(room)}/followers`, 'getTopicFollowers', 'followers') } - async search (q, { limit = 50, offset = 0 } = {}) { + async search (q, { limit = 50, offset = 0, viewer = null } = {}) { try { + const params = { q, limit, offset } + if (viewer) params.viewer = viewer const result = await this.axios.get(`${config.backend}/search`, { - params: { q, limit, offset } + params }) return result.data diff --git a/psf-memo-client/src/services/recent-feed-page.js b/psf-memo-client/src/services/recent-feed-page.js index 341a628..2d16930 100644 --- a/psf-memo-client/src/services/recent-feed-page.js +++ b/psf-memo-client/src/services/recent-feed-page.js @@ -21,6 +21,26 @@ class RecentFeedPage extends PaginatedPage { loadMethod: 'getRecentPosts', errorMessage: 'Recent feed page requires a memo db client.' }) + this.wallet = deps.wallet || null + } + + getMyAddress () { + return this.wallet?.walletInfo?.cashAddress || null + } + + async load ({ limit = 50, offset = 0 } = {}) { + if (!this.memoDb) { + throw new Error('Recent feed page requires a memo db client.') + } + + const opts = { limit, offset } + const viewer = this.getMyAddress() + if (viewer) opts.viewer = viewer + const data = await this.memoDb.getRecentPosts(opts) + this.posts = data.posts || [] + this.pagination = data.pagination || null + + return { posts: this.posts, pagination: this.pagination } } getPost (txid) { diff --git a/psf-memo-client/src/services/search-page.js b/psf-memo-client/src/services/search-page.js index a0b174c..c2f83fa 100644 --- a/psf-memo-client/src/services/search-page.js +++ b/psf-memo-client/src/services/search-page.js @@ -11,6 +11,7 @@ const SEARCH_PATH = '/search' class SearchPage { constructor (deps = {}) { this.memoDb = deps.memoDb || null + this.wallet = deps.wallet || null this.navigate = deps.navigate || (() => {}) this.query = '' this.posts = [] @@ -18,6 +19,10 @@ class SearchPage { this.pagination = null } + getMyAddress () { + return this.wallet?.walletInfo?.cashAddress || null + } + setQuery (q) { this.query = typeof q === 'string' ? q.trim() : '' return this @@ -28,7 +33,10 @@ class SearchPage { throw new Error('Search page requires a memo db client.') } - const data = await this.memoDb.search(this.query, { limit, offset }) + const opts = { limit, offset } + const viewer = this.getMyAddress() + if (viewer) opts.viewer = viewer + const data = await this.memoDb.search(this.query, opts) this.posts = data.posts || [] this.profiles = data.profiles || [] this.pagination = data.pagination || null diff --git a/psf-memo-client/src/services/topic-feed-page.js b/psf-memo-client/src/services/topic-feed-page.js index 892cbfd..7dba335 100644 --- a/psf-memo-client/src/services/topic-feed-page.js +++ b/psf-memo-client/src/services/topic-feed-page.js @@ -26,7 +26,9 @@ class TopicFeedPage { throw new Error('Topic feed page requires a topic room.') } - const data = await this.memoDb.getTopicPosts(this.room, { limit, offset }) + const opts = { limit, offset } + if (this.myAddr) opts.viewer = this.myAddr + const data = await this.memoDb.getTopicPosts(this.room, opts) this.posts = data.posts || [] this.pagination = data.pagination || null this.followState = await this._loadFollowState() diff --git a/psf-memo-client/test/unit/recent-feed-page.test.js b/psf-memo-client/test/unit/recent-feed-page.test.js index c710e38..3b29690 100644 --- a/psf-memo-client/test/unit/recent-feed-page.test.js +++ b/psf-memo-client/test/unit/recent-feed-page.test.js @@ -57,13 +57,35 @@ test('load forwards limit and offset to the memo db client', async () => { assert.deepEqual(calls, [{ limit: 10, offset: 20 }]) }) -test('load throws when no memo db client is provided', async () => { - const page = new RecentFeedPage({}) +test('load forwards the viewer address to the memo db client when a wallet is provided', async () => { + const calls = [] + const memoDb = { + async getRecentPosts (params) { + calls.push(params) + return { posts: [], pagination: {} } + } + } + const wallet = { walletInfo: { cashAddress: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' } } + const page = new RecentFeedPage({ memoDb, wallet }) - await assert.rejects( - () => page.load(), - /requires a memo db client/ - ) + await page.load({ limit: 10, offset: 20 }) + + assert.deepEqual(calls, [{ limit: 10, offset: 20, viewer: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' }]) +}) + +test('load omits viewer address when no wallet is provided', async () => { + const calls = [] + const memoDb = { + async getRecentPosts (params) { + calls.push(params) + return { posts: [], pagination: {} } + } + } + const page = new RecentFeedPage({ memoDb }) + + await page.load({ limit: 10, offset: 20 }) + + assert.deepEqual(calls, [{ limit: 10, offset: 20 }]) }) test('load defaults limit to 50 and offset to 0', async () => { @@ -80,3 +102,12 @@ test('load defaults limit to 50 and offset to 0', async () => { assert.deepEqual(calls, [{ limit: 50, offset: 0 }]) }) + +test('load throws when no memo db client is provided', async () => { + const page = new RecentFeedPage({}) + + await assert.rejects( + () => page.load(), + /requires a memo db client/ + ) +}) diff --git a/psf-memo-client/test/unit/search-page.test.js b/psf-memo-client/test/unit/search-page.test.js index 506963f..009e098 100644 --- a/psf-memo-client/test/unit/search-page.test.js +++ b/psf-memo-client/test/unit/search-page.test.js @@ -49,6 +49,30 @@ test('submit forwards query, limit and offset to the memo db client', async () = assert.deepEqual(calls, [{ q: 'alice', params: { limit: 10, offset: 20 } }]) }) +test('submit forwards the viewer address when a wallet is provided', async () => { + const calls = [] + const memoDb = { + async search (q, params) { + calls.push({ q, params }) + return { posts: [], profiles: [], pagination: {} } + } + } + const wallet = { walletInfo: { cashAddress: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' } } + const page = new SearchPage({ memoDb, wallet }) + + page.setQuery('alice') + await page.submit({ limit: 10, offset: 20 }) + + assert.deepEqual(calls, [{ + q: 'alice', + params: { + limit: 10, + offset: 20, + viewer: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + } + }]) +}) + test('submit defaults limit to 50 and offset to 0', async () => { const calls = [] const memoDb = { diff --git a/psf-memo-client/test/unit/topic-feed-page.test.js b/psf-memo-client/test/unit/topic-feed-page.test.js index 5f4557e..178ef01 100644 --- a/psf-memo-client/test/unit/topic-feed-page.test.js +++ b/psf-memo-client/test/unit/topic-feed-page.test.js @@ -71,6 +71,34 @@ test('load forwards limit and offset to the memo db client', async () => { assert.deepEqual(calls, [{ room: 'bitcoin', params: { limit: 10, offset: 20 } }]) }) +test('load forwards the viewer address when myAddr is provided', async () => { + const calls = [] + const memoDb = { + async getTopicPosts (room, params) { + calls.push({ room, params }) + return { posts: [], pagination: {} } + }, + async getTopicFollowState () { + return false + }, + async getTopicFollowers () { + return [] + } + } + const page = new TopicFeedPage({ memoDb, room: 'bitcoin', myAddr: MY_ADDRESS }) + + await page.load({ limit: 10, offset: 20 }) + + assert.deepEqual(calls, [{ + room: 'bitcoin', + params: { + limit: 10, + offset: 20, + viewer: MY_ADDRESS + } + }]) +}) + test('load defaults limit and offset to 50 and 0', async () => { const calls = [] const memoDb = { diff --git a/psf-memo-db/src/adapters/index.js b/psf-memo-db/src/adapters/index.js index 5c751ca..8007a80 100644 --- a/psf-memo-db/src/adapters/index.js +++ b/psf-memo-db/src/adapters/index.js @@ -35,7 +35,8 @@ class Adapters { postParentsDb: level.postParentsDb, postChildrenDb: level.postChildrenDb, likesDb: level.likesDb, - postLikesDb: level.postLikesDb + postLikesDb: level.postLikesDb, + muteQuery: this.muteQuery }) this.followQuery = new FollowQuery({ followsDb: level.followsDb @@ -45,7 +46,8 @@ class Adapters { }) this.topicQuery = new TopicQuery({ roomsDb: level.roomsDb, - postsDb: level.postsDb + postsDb: level.postsDb, + muteQuery: this.muteQuery }) this.pollQuery = new PollQuery({ pollsDb: level.pollsDb, @@ -56,7 +58,8 @@ class Adapters { postsDb: level.postsDb, postParentsDb: level.postParentsDb, namesDb: level.namesDb, - profilesDb: level.profilesDb + profilesDb: level.profilesDb, + muteQuery: this.muteQuery }) this.notificationsQuery = new NotificationsQuery({ postsDb: level.postsDb, @@ -64,7 +67,8 @@ class Adapters { postChildrenDb: level.postChildrenDb, likesDb: level.likesDb, postLikesDb: level.postLikesDb, - followsDb: level.followsDb + followsDb: level.followsDb, + muteQuery: this.muteQuery }) return true } diff --git a/psf-memo-db/src/adapters/notifications-query.js b/psf-memo-db/src/adapters/notifications-query.js index 85fd102..4ba43f2 100644 --- a/psf-memo-db/src/adapters/notifications-query.js +++ b/psf-memo-db/src/adapters/notifications-query.js @@ -18,6 +18,7 @@ class NotificationsQuery { likesDb, postLikesDb, followsDb, + muteQuery, bchjs = new BCHJS({ restURL: process.env.RESTURL || 'https://api.fullstack.cash/v5/' }) } = localConfig @@ -46,6 +47,7 @@ class NotificationsQuery { this.likesDb = likesDb this.postLikesDb = postLikesDb this.followsDb = followsDb + this.muteQuery = muteQuery || null this.bchjs = bchjs this.listNotifications = this.listNotifications.bind(this) @@ -57,7 +59,7 @@ class NotificationsQuery { } // Collect active follows where this address is the followee. - async _collectFollowNotifications (addr) { + async _collectFollowNotifications (addr, mutedAddrs) { const myHash160 = this.bchjs.Address.toHash160(addr) const notifications = [] @@ -67,6 +69,7 @@ class NotificationsQuery { const followerAddr = record.followerAddr || key.split(':')[0] if (followerAddr === addr) continue + if (mutedAddrs.has(followerAddr)) continue notifications.push({ type: 'follow', @@ -81,11 +84,12 @@ class NotificationsQuery { } // Collect likes on posts authored by this address, excluding self-likes. - async _collectLikeNotifications (addr) { + async _collectLikeNotifications (addr, mutedAddrs) { const notifications = [] for await (const [likeTxid, like] of this.likesDb.iterator()) { if (!like || like.addr === addr) continue + if (mutedAddrs.has(like.addr)) continue const post = await getPostOrNull(this.postsDb, like.postTxid) if (!post || post.addr !== addr) continue @@ -104,7 +108,7 @@ class NotificationsQuery { } // Collect replies to posts authored by this address, excluding own replies. - async _collectReplyNotifications (addr) { + async _collectReplyNotifications (addr, mutedAddrs) { const notifications = [] for await (const [, child] of this.postChildrenDb.iterator()) { @@ -114,6 +118,7 @@ class NotificationsQuery { const childPost = await this._replyNotificationChild(child, addr) if (!childPost) continue + if (mutedAddrs.has(childPost.addr)) continue notifications.push({ type: 'reply', @@ -153,9 +158,11 @@ class NotificationsQuery { // Return paginated notifications for addr, sorted newest-first. async listNotifications (addr, { limit, offset } = {}) { - const follows = await this._collectFollowNotifications(addr) - const likes = await this._collectLikeNotifications(addr) - const replies = await this._collectReplyNotifications(addr) + const mutedAddrs = await this._mutedAddrs(addr) + + const follows = await this._collectFollowNotifications(addr, mutedAddrs) + const likes = await this._collectLikeNotifications(addr, mutedAddrs) + const replies = await this._collectReplyNotifications(addr, mutedAddrs) const all = this._sortNotifications(follows.concat(likes).concat(replies)) const total = all.length @@ -163,6 +170,14 @@ class NotificationsQuery { return { notifications: page, total } } + + // Return a Set of addresses muted by addr, or an empty set when no mute + // query adapter is available. + async _mutedAddrs (addr) { + if (!this.muteQuery || !addr) return new Set() + const muted = await this.muteQuery.listMuted(addr) + return new Set(muted) + } } export default NotificationsQuery diff --git a/psf-memo-db/src/adapters/post-query.js b/psf-memo-db/src/adapters/post-query.js index cecad21..a0219cd 100644 --- a/psf-memo-db/src/adapters/post-query.js +++ b/psf-memo-db/src/adapters/post-query.js @@ -13,7 +13,7 @@ const HEIGHT_PAD = 12 class PostQuery { constructor (localConfig = {}) { - const { postsDb, postHeightsDb, addrPostHeightsDb, postParentsDb, postChildrenDb, likesDb, postLikesDb } = localConfig + const { postsDb, postHeightsDb, addrPostHeightsDb, postParentsDb, postChildrenDb, likesDb, postLikesDb, muteQuery } = localConfig if (!postsDb) { throw new Error('postsDb required when instantiating PostQuery adapter.') } @@ -42,6 +42,7 @@ class PostQuery { this.postChildrenDb = postChildrenDb this.likesDb = likesDb this.postLikesDb = postLikesDb + this.muteQuery = muteQuery || null this.scanRecentPostTxids = this.scanRecentPostTxids.bind(this) this.scanPostsByAddrTxids = this.scanPostsByAddrTxids.bind(this) @@ -199,11 +200,14 @@ class PostQuery { } } - async scanRecentPostTxids ({ limit, offset }) { + async scanRecentPostTxids ({ limit, offset, viewerAddr = null }) { + const mutedAddrs = await this._mutedAddrs(viewerAddr) const txids = [] let skipped = 0 for await (const txid of this.topLevelPostTxids({ reverse: true })) { + if (await this._isMutedPost(txid, mutedAddrs)) continue + if (skipped < offset) { skipped++ continue @@ -274,13 +278,15 @@ class PostQuery { return posts } - async countTopLevelPosts () { + async countTopLevelPosts (viewerAddr = null) { + const mutedAddrs = await this._mutedAddrs(viewerAddr) let count = 0 const iterator = this.topLevelPostTxids() for (;;) { - const { done } = await iterator.next() + const { done, value: txid } = await iterator.next() if (done) break + if (await this._isMutedPost(txid, mutedAddrs)) continue count++ } @@ -343,6 +349,23 @@ class PostQuery { if (!post) return false return followeeSet.has(post.addr) } + + // Return a Set of addresses muted by viewerAddr, or an empty set when no + // mute query adapter is available. + async _mutedAddrs (viewerAddr) { + if (!this.muteQuery || !viewerAddr) return new Set() + const muted = await this.muteQuery.listMuted(viewerAddr) + return new Set(muted) + } + + // True when a post is authored by an address in mutedAddrs. Missing posts + // are treated as not muted. + async _isMutedPost (txid, mutedAddrs) { + if (mutedAddrs.size === 0) return false + const post = await this.getPostOrNull(txid) + if (!post) return false + return mutedAddrs.has(post.addr) + } } export default PostQuery diff --git a/psf-memo-db/src/adapters/search-query.js b/psf-memo-db/src/adapters/search-query.js index e6497d0..e2d279e 100644 --- a/psf-memo-db/src/adapters/search-query.js +++ b/psf-memo-db/src/adapters/search-query.js @@ -12,7 +12,7 @@ import { loadReplyTxids } from './lib/load-reply-txids.js' class SearchQuery { constructor (localConfig = {}) { - const { postsDb, postParentsDb, namesDb, profilesDb } = localConfig + const { postsDb, postParentsDb, namesDb, profilesDb, muteQuery } = localConfig if (!postsDb) { throw new Error('postsDb required when instantiating SearchQuery adapter.') } @@ -29,21 +29,24 @@ class SearchQuery { this.postParentsDb = postParentsDb this.namesDb = namesDb this.profilesDb = profilesDb + this.muteQuery = muteQuery || null this.searchPosts = this.searchPosts.bind(this) this.searchProfiles = this.searchProfiles.bind(this) this.profileMatches = this.profileMatches.bind(this) } - async searchPosts (query) { + async searchPosts (query, { viewerAddr = null } = {}) { const normalized = normalizeQuery(query) if (normalized.length === 0) return [] const replyTxids = await loadReplyTxids(this.postParentsDb) + const mutedAddrs = await this._mutedAddrs(viewerAddr) const matches = [] for await (const [txid, post] of this.postsDb.iterator()) { if (this.isMatchingPost(txid, post, replyTxids, normalized)) { + if (mutedAddrs.has(post.addr)) continue matches.push({ txid, addr: post.addr, @@ -133,6 +136,14 @@ class SearchQuery { blockHeight } } + + // Return a Set of addresses muted by viewerAddr, or an empty set when no + // mute query adapter is available. + async _mutedAddrs (viewerAddr) { + if (!this.muteQuery || !viewerAddr) return new Set() + const muted = await this.muteQuery.listMuted(viewerAddr) + return new Set(muted) + } } export default SearchQuery diff --git a/psf-memo-db/src/adapters/topic-query.js b/psf-memo-db/src/adapters/topic-query.js index 43a4de5..a8ef5cb 100644 --- a/psf-memo-db/src/adapters/topic-query.js +++ b/psf-memo-db/src/adapters/topic-query.js @@ -12,7 +12,7 @@ class TopicQuery { constructor (localConfig = {}) { - const { roomsDb, postsDb } = localConfig + const { roomsDb, postsDb, muteQuery } = localConfig if (!roomsDb) { throw new Error('roomsDb required when instantiating TopicQuery adapter.') } @@ -21,6 +21,7 @@ class TopicQuery { } this.roomsDb = roomsDb this.postsDb = postsDb + this.muteQuery = muteQuery || null this.listTopics = this.listTopics.bind(this) this.getTopicPostTxids = this.getTopicPostTxids.bind(this) @@ -72,7 +73,8 @@ class TopicQuery { .map(({ room, postCount }) => ({ room, postCount })) } - async getTopicPostTxids (room, { limit, offset }) { + async getTopicPostTxids (room, { limit, offset, viewerAddr = null }) { + const mutedAddrs = await this._mutedAddrs(viewerAddr) const start = `${room}:` const end = `${room}:\uffff` const entries = [] @@ -80,6 +82,7 @@ class TopicQuery { for await (const [key, value] of this.roomsDb.iterator({ gte: start, lte: end })) { if (value?.type !== 'post') continue const txid = (value && typeof value.txid === 'string') ? value.txid : this.txidFromKey(key) + if (await this._isMutedPost(txid, mutedAddrs)) continue const blockHeight = value?.blockHeight ?? 0 entries.push({ txid, blockHeight }) } @@ -92,6 +95,23 @@ class TopicQuery { return { txids, total } } + // Return a Set of addresses muted by viewerAddr, or an empty set when no + // mute query adapter is available. + async _mutedAddrs (viewerAddr) { + if (!this.muteQuery || !viewerAddr) return new Set() + const muted = await this.muteQuery.listMuted(viewerAddr) + return new Set(muted) + } + + // True when a post is authored by an address in mutedAddrs. Missing posts + // are treated as not muted. + async _isMutedPost (txid, mutedAddrs) { + if (mutedAddrs.size === 0) return false + const post = await this.postsDb.get(txid).catch(() => null) + if (!post) return false + return mutedAddrs.has(post.addr) + } + // Return true when addr has an active follow record for room. async isFollowingRoom (addr, room) { const key = `${room}:${addr}` diff --git a/psf-memo-db/src/controllers/rest-api/posts/controller.js b/psf-memo-db/src/controllers/rest-api/posts/controller.js index 1a98328..adeb23b 100644 --- a/psf-memo-db/src/controllers/rest-api/posts/controller.js +++ b/psf-memo-db/src/controllers/rest-api/posts/controller.js @@ -62,6 +62,7 @@ class PostsRESTControllerLib { * * @apiQuery {Number} [limit=100] Page size (max 100) * @apiQuery {Number} [offset=0] Number of posts to skip after sorting + * @apiQuery {String} [viewer] Viewer cash address; posts from addresses the viewer mutes are excluded * * @apiExample Example usage: * curl -X GET "localhost:5021/posts/recent?limit=50&offset=0" @@ -80,8 +81,10 @@ class PostsRESTControllerLib { * @apiSuccess {Boolean} pagination.hasMore True if more pages exist */ async getRecentPosts (ctx) { - const { limit, offset } = ctx.query - await this.runUseCase(ctx, () => this.useCases.listRecentPosts.execute({ limit, offset })) + const { limit, offset, viewer } = ctx.query + const args = { limit, offset } + if (viewer) args.viewerAddr = viewer + await this.runUseCase(ctx, () => this.useCases.listRecentPosts.execute(args)) } /** diff --git a/psf-memo-db/src/controllers/rest-api/search/controller.js b/psf-memo-db/src/controllers/rest-api/search/controller.js index 85f22bb..e34c37d 100644 --- a/psf-memo-db/src/controllers/rest-api/search/controller.js +++ b/psf-memo-db/src/controllers/rest-api/search/controller.js @@ -36,6 +36,7 @@ class SearchRESTControllerLib { * @apiQuery {String} q Search query * @apiQuery {Number} [limit=100] Page size (max 100) * @apiQuery {Number} [offset=0] Number of results to skip after sorting + * @apiQuery {String} [viewer] Viewer cash address; posts from addresses the viewer mutes are excluded * * @apiExample Example usage: * curl -X GET "localhost:5021/search?q=hello&limit=50&offset=0" @@ -46,8 +47,10 @@ class SearchRESTControllerLib { */ async search (ctx) { try { - const { q, limit, offset } = ctx.query - ctx.body = await this.useCases.searchAll.execute({ q, limit, offset }) + const { q, limit, offset, viewer } = ctx.query + const args = { q, limit, offset } + if (viewer) args.viewerAddr = viewer + ctx.body = await this.useCases.searchAll.execute(args) } catch (err) { this.handleError(ctx, err) } diff --git a/psf-memo-db/src/controllers/rest-api/topics/controller.js b/psf-memo-db/src/controllers/rest-api/topics/controller.js index a82d796..0770cc2 100644 --- a/psf-memo-db/src/controllers/rest-api/topics/controller.js +++ b/psf-memo-db/src/controllers/rest-api/topics/controller.js @@ -61,6 +61,7 @@ class TopicsRESTControllerLib { * @apiParam {String} room Topic name * @apiQuery {Number} [limit=100] Page size (max 100) * @apiQuery {Number} [offset=0] Number of posts to skip after sorting + * @apiQuery {String} [viewer] Viewer cash address; posts from addresses the viewer mutes are excluded * * @apiExample Example usage: * curl -X GET "localhost:5021/topics/bitcoin/posts?limit=50&offset=0" @@ -77,8 +78,10 @@ class TopicsRESTControllerLib { async getTopicPosts (ctx) { try { const { room } = ctx.params - const { limit, offset } = ctx.query - ctx.body = await this.useCases.listTopicPosts.execute({ room, limit, offset }) + const { limit, offset, viewer } = ctx.query + const args = { room, limit, offset } + if (viewer) args.viewerAddr = viewer + ctx.body = await this.useCases.listTopicPosts.execute(args) } catch (err) { this.handleError(ctx, err) } diff --git a/psf-memo-db/src/use-cases/list-recent-posts.js b/psf-memo-db/src/use-cases/list-recent-posts.js index 53eb9ab..dfc8400 100644 --- a/psf-memo-db/src/use-cases/list-recent-posts.js +++ b/psf-memo-db/src/use-cases/list-recent-posts.js @@ -14,13 +14,16 @@ class ListRecentPosts extends ListUseCase { async execute (inObj = {}) { const limit = parseLimit(inObj.limit) const offset = parseOffset(inObj.offset) + const viewerAddr = inObj.viewerAddr || inObj.viewer || null - const txids = await this.adapters.postQuery.scanRecentPostTxids({ limit, offset }) + const scanArgs = { limit, offset } + if (viewerAddr) scanArgs.viewerAddr = viewerAddr + const txids = await this.adapters.postQuery.scanRecentPostTxids(scanArgs) const [posts, replyCounts, likeCounts, total] = await Promise.all([ this.adapters.postQuery.loadPostsByTxids(txids), this.adapters.postQuery.buildReplyCountMap(), this.adapters.postQuery.countLikesForTxids(txids), - this.adapters.postQuery.countTopLevelPosts() + this.adapters.postQuery.countTopLevelPosts(viewerAddr) ]) return assemblePostPage({ posts, replyCounts, likeCounts, total, limit, offset }) diff --git a/psf-memo-db/src/use-cases/list-topic-posts.js b/psf-memo-db/src/use-cases/list-topic-posts.js index 7b60035..e775ba7 100644 --- a/psf-memo-db/src/use-cases/list-topic-posts.js +++ b/psf-memo-db/src/use-cases/list-topic-posts.js @@ -25,8 +25,9 @@ class ListTopicPosts { const room = parseRequiredString(inObj.room, 'room') const limit = parseLimit(inObj.limit) const offset = parseOffset(inObj.offset) + const viewerAddr = inObj.viewerAddr || inObj.viewer || null - const { txids, total } = await this.adapters.topicQuery.getTopicPostTxids(room, { limit, offset }) + const { txids, total } = await this.adapters.topicQuery.getTopicPostTxids(room, { limit, offset, viewerAddr }) const [posts, replyCounts, likeCounts] = await Promise.all([ this.adapters.postQuery.loadPostsByTxids(txids), this.adapters.postQuery.countRepliesForTxids(txids), diff --git a/psf-memo-db/src/use-cases/search-all.js b/psf-memo-db/src/use-cases/search-all.js index e551dba..5c97b16 100644 --- a/psf-memo-db/src/use-cases/search-all.js +++ b/psf-memo-db/src/use-cases/search-all.js @@ -19,13 +19,14 @@ class SearchAll extends ListUseCase { const q = normalizeQuery(inObj.q) const limit = parseLimit(inObj.limit) const offset = parseOffset(inObj.offset) + const viewerAddr = inObj.viewerAddr || inObj.viewer || null if (q.length === 0) { return this.emptyResult(limit, offset) } const [allPosts, allProfiles] = await Promise.all([ - this.adapters.searchQuery.searchPosts(q), + this.adapters.searchQuery.searchPosts(q, { viewerAddr }), this.adapters.searchQuery.searchProfiles(q) ]) diff --git a/psf-memo-db/test/unit/adapters/notifications-query.unit.js b/psf-memo-db/test/unit/adapters/notifications-query.unit.js index 7977395..ac0b479 100644 --- a/psf-memo-db/test/unit/adapters/notifications-query.unit.js +++ b/psf-memo-db/test/unit/adapters/notifications-query.unit.js @@ -113,6 +113,42 @@ describe('#NotificationsQuery', () => { } }) + it('should exclude notifications from muted addresses when a mute query is provided', async () => { + const myPostTxid = 'a'.repeat(64) + const replyTxid = 'b'.repeat(64) + const likeTxid = 'c'.repeat(64) + + postChildrenDb.iterator.returns(makeIterator([ + [`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 100 }] + ])) + likesDb.iterator.returns(makeIterator([ + [likeTxid, { addr: THEIR_ADDR, postTxid: myPostTxid, blockHeight: 300 }] + ])) + followsDb.iterator.returns(makeIterator([])) + + postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' }) + postsDb.get.withArgs(replyTxid).resolves({ addr: THEIR_ADDR, text: 'reply' }) + + const muteQuery = { + listMuted: sandbox.stub().resolves([THEIR_ADDR]) + } + uut = new NotificationsQuery({ + postsDb, + postParentsDb: {}, + postChildrenDb, + likesDb, + postLikesDb: {}, + followsDb, + muteQuery, + bchjs + }) + + const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 }) + + assert.equal(result.total, 0) + assert.isTrue(muteQuery.listMuted.calledOnceWith(MY_ADDR)) + }) + it('should return null from getPostOrNull when the post is not found', async () => { const missingTxid = 'c'.repeat(64) const notFound = new Error('not found') diff --git a/psf-memo-db/test/unit/adapters/post-query.unit.js b/psf-memo-db/test/unit/adapters/post-query.unit.js index 00d0b75..5e3a15b 100644 --- a/psf-memo-db/test/unit/adapters/post-query.unit.js +++ b/psf-memo-db/test/unit/adapters/post-query.unit.js @@ -606,4 +606,75 @@ describe('#PostQuery', () => { assert.equal(result.get('tx3'), 1) }) }) + + describe('#mute filtering', () => { + it('should exclude posts from muted addresses in recent posts', async () => { + async function * mockHeights () { + yield ['000000600300:post-300-muted', { txid: 'post-300-muted' }] + yield ['000000600200:post-200', { txid: 'post-200' }] + yield ['000000600100:post-100-muted', { txid: 'post-100-muted' }] + } + postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights()) + postsDb.get.callsFake(async (txid) => { + if (txid === 'post-300-muted') return { addr: 'muted-addr', text: 'x', seen: 1, blockHeight: 600300 } + if (txid === 'post-200') return { addr: 'other-addr', text: 'x', seen: 2, blockHeight: 600200 } + if (txid === 'post-100-muted') return { addr: 'muted-addr', text: 'x', seen: 3, blockHeight: 600100 } + const err = new Error('not found') + err.notFound = true + throw err + }) + + const muteQuery = { + listMuted: sandbox.stub().resolves(['muted-addr']) + } + uut = new PostQuery({ + postsDb, + postHeightsDb, + addrPostHeightsDb, + postParentsDb, + postChildrenDb, + likesDb, + postLikesDb, + muteQuery + }) + + const result = await uut.scanRecentPostTxids({ limit: 2, offset: 0, viewerAddr: 'viewer-addr' }) + + assert.deepEqual(result, ['post-200']) + assert.isTrue(muteQuery.listMuted.calledOnceWith('viewer-addr')) + }) + + it('should count top-level posts excluding muted addresses', async () => { + async function * mockHeights () { + yield ['000000600200:post-200-muted', { txid: 'post-200-muted' }] + yield ['000000600100:post-100', { txid: 'post-100' }] + } + postHeightsDb.iterator.withArgs({ reverse: false }).returns(mockHeights()) + postsDb.get.callsFake(async (txid) => { + if (txid === 'post-200-muted') return { addr: 'muted-addr', text: 'x', seen: 1, blockHeight: 600200 } + if (txid === 'post-100') return { addr: 'other-addr', text: 'x', seen: 2, blockHeight: 600100 } + const err = new Error('not found') + err.notFound = true + throw err + }) + + const muteQuery = { + listMuted: sandbox.stub().resolves(['muted-addr']) + } + uut = new PostQuery({ + postsDb, + postHeightsDb, + addrPostHeightsDb, + postParentsDb, + postChildrenDb, + likesDb, + postLikesDb, + muteQuery + }) + + const result = await uut.countTopLevelPosts('viewer-addr') + + assert.equal(result, 1) + }) + }) }) diff --git a/psf-memo-db/test/unit/adapters/search-query.unit.js b/psf-memo-db/test/unit/adapters/search-query.unit.js index b2b6625..ee09eb8 100644 --- a/psf-memo-db/test/unit/adapters/search-query.unit.js +++ b/psf-memo-db/test/unit/adapters/search-query.unit.js @@ -123,6 +123,23 @@ describe('#SearchQuery', () => { assert.equal(result[0].text, 'bitcoin cash enthusiast') }) + it('should exclude posts from muted addresses when a viewer is provided', async () => { + postsDb.iterator = () => makeIterator([ + ['tx1', { addr: 'muted-addr', text: 'hello world', seen: 100, blockHeight: 600100 }], + ['tx2', { addr: 'other-addr', text: 'hello again', seen: 200, blockHeight: 600200 }] + ])() + postParentsDb.iterator = () => makeIterator([])() + + const muteQuery = { + listMuted: async () => ['muted-addr'] + } + const uut = new SearchQuery({ postsDb, postParentsDb, namesDb, profilesDb, muteQuery }) + const result = await uut.searchPosts('hello', { viewerAddr: 'viewer-addr' }) + + assert.equal(result.length, 1) + assert.equal(result[0].txid, 'tx2') + }) + it('should return no posts when query is empty', async () => { postsDb.iterator = () => makeIterator([ ['tx1', { addr: 'addr1', text: 'hello world', seen: 100, blockHeight: 600100 }] diff --git a/psf-memo-db/test/unit/adapters/topic-query.unit.js b/psf-memo-db/test/unit/adapters/topic-query.unit.js index b513187..4d6fe69 100644 --- a/psf-memo-db/test/unit/adapters/topic-query.unit.js +++ b/psf-memo-db/test/unit/adapters/topic-query.unit.js @@ -239,19 +239,32 @@ describe('#TopicQuery', () => { assert.equal(result.total, 1) }) - it('should treat a post without a block height as height 0', async () => { + it('should exclude posts from muted addresses when a viewer is provided', async () => { async function * mockRooms () { - yield ['bitcoin:post-a', { room: 'bitcoin', txid: 'post-a', type: 'post', blockHeight: 0 }] - yield ['bitcoin:post-b', { room: 'bitcoin', txid: 'post-b', type: 'post' }] + yield ['bitcoin:post-300', { room: 'bitcoin', txid: 'post-300', type: 'post', blockHeight: 300 }] + yield ['bitcoin:post-200', { room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 200 }] } roomsDb.iterator .withArgs(sinon.match({ gte: 'bitcoin:', lte: 'bitcoin:\uffff' })) .returns(mockRooms()) + postsDb.get.callsFake(async (txid) => { + if (txid === 'post-300') return { addr: 'muted-addr', text: 'x', blockHeight: 300 } + if (txid === 'post-200') return { addr: 'other-addr', text: 'x', blockHeight: 200 } + const err = new Error('not found') + err.notFound = true + throw err + }) - const result = await uut.getTopicPostTxids('bitcoin', { limit: 100, offset: 0 }) + const muteQuery = { + listMuted: sandbox.stub().resolves(['muted-addr']) + } + uut = new TopicQuery({ roomsDb, postsDb, muteQuery }) - assert.deepEqual(result.txids, ['post-a', 'post-b']) - assert.equal(result.total, 2) + const result = await uut.getTopicPostTxids('bitcoin', { limit: 100, offset: 0, viewerAddr: 'viewer-addr' }) + + assert.deepEqual(result.txids, ['post-200']) + assert.equal(result.total, 1) + assert.isTrue(muteQuery.listMuted.calledOnceWith('viewer-addr')) }) })