From 6d1bd148e97b6a47cce52eb019e6f646cf7615bf Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sat, 29 Aug 2026 10:30:54 -0700 Subject: [PATCH] Implement Following feed Add psf-memo-db endpoint GET /posts/following/:addr that returns top-level posts from followed profiles, excluding replies and the viewer's own posts, newest first and paginated. Add PostQuery.scanFollowingFeedTxidsAndCount, ListFollowingFeed use case, and REST controller/router wiring. Add psf-memo-client Following feed page, route /posts/following, nav link, MemoDb.getFollowingFeed, and FollowingFeedPage service. Update acceptance handlers for the new following-feed.feature. By coder. --- psf-memo-client/acceptance/lib/handlers.js | 192 +++++++++++++++++- .../app-body/following-feed/index.js | 180 ++++++++++++++++ .../src/components/app-body/index.js | 2 + .../src/components/nav-menu/index.js | 8 + .../src/services/following-feed-page.js | 54 +++++ psf-memo-client/src/services/memo-db.js | 4 + .../test/unit/following-feed-page.test.js | 145 +++++++++++++ psf-memo-db/src/adapters/post-query.js | 34 ++++ .../controllers/rest-api/posts/controller.js | 36 ++++ .../src/controllers/rest-api/posts/index.js | 1 + psf-memo-db/src/use-cases/index.js | 6 + .../src/use-cases/list-following-feed.js | 55 +++++ .../test/unit/adapters/post-query.unit.js | 104 ++++++++++ .../unit/controllers/posts.controller.unit.js | 25 +++ .../use-cases/list-following-feed.unit.js | 116 +++++++++++ 15 files changed, 960 insertions(+), 2 deletions(-) create mode 100644 psf-memo-client/src/components/app-body/following-feed/index.js create mode 100644 psf-memo-client/src/services/following-feed-page.js create mode 100644 psf-memo-client/test/unit/following-feed-page.test.js create mode 100644 psf-memo-db/src/use-cases/list-following-feed.js create mode 100644 psf-memo-db/test/unit/use-cases/list-following-feed.unit.js diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 7b98301..6cfae7a 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -35,6 +35,7 @@ const LikeTipPage = require('../../src/services/like-tip-page') const MemoFollow = require('../../src/services/memo-follow') const MemoMute = require('../../src/services/memo-mute') const RecentFeedPage = require('../../src/services/recent-feed-page') +const FollowingFeedPage = require('../../src/services/following-feed-page') const ProfilePage = require('../../src/services/profile-page') const ThreadPage = require('../../src/services/thread-page') const TopicDiscoveryPage = require('../../src/services/topic-discovery-page') @@ -188,6 +189,7 @@ function makeMemoDb () { const threads = {} const followState = {} const muteState = {} + const replyTxids = new Set() const topics = [] const topicPosts = {} const topicCounts = new Map() @@ -206,6 +208,10 @@ function makeMemoDb () { addPost (post) { posts.push(post) }, + addReply (reply) { + replyTxids.add(reply.txid) + posts.push(reply) + }, addSearchPost (post) { searchPosts.push(post) }, @@ -234,7 +240,7 @@ function makeMemoDb () { threads[txid] = thread }, setFollowState (followerAddr, followeeAddr, following) { - followState[`${followerAddr}:${followeeAddr}`] = following + followState[`${followerAddr}|${followeeAddr}`] = following }, setMuteState (muterAddr, muteeAddr, muted) { muteState[`${muterAddr}:${muteeAddr}`] = muted @@ -285,7 +291,7 @@ function makeMemoDb () { return threads[txid] || { post: null } }, async getFollowState (followerAddr, followeeAddr) { - return followState[`${followerAddr}:${followeeAddr}`] || false + return followState[`${followerAddr}|${followeeAddr}`] || false }, async getMuteState (muterAddr, muteeAddr) { return muteState[`${muterAddr}:${muteeAddr}`] || false @@ -302,6 +308,19 @@ function makeMemoDb () { const all = topicPosts[room] || [] const page = all.slice(offset, offset + limit) return { posts: page, pagination: { total: all.length, limit, offset, hasMore: offset + page.length < all.length } } + }, + async getFollowingFeed (addr, { limit = 100, offset = 0 } = {}) { + const followees = new Set() + for (const [key, following] of Object.entries(followState)) { + if (!following) continue + const [follower, followee] = key.split('|') + if (follower === addr) followees.add(followee) + } + const all = posts + .filter((p) => followees.has(p.addr) && p.addr !== addr && !replyTxids.has(p.txid)) + .sort((a, b) => (b.blockHeight ?? 0) - (a.blockHeight ?? 0)) + const page = all.slice(offset, offset + limit) + return { posts: page, pagination: { total: all.length, limit, offset, hasMore: offset + page.length < all.length } } } } } @@ -342,6 +361,7 @@ function createWorld () { // Read-only page controllers backed by the fake psf-memo-db API. world.recentFeedPage = new RecentFeedPage({ memoDb }) + world.followingFeedPage = new FollowingFeedPage({ memoDb, wallet }) world.profilePage = new ProfilePage({ memoDb }) world.threadPage = new ThreadPage({ memoDb }) world.topicDiscoveryPage = new TopicDiscoveryPage({ @@ -434,6 +454,15 @@ function resolveParam (value, example) { return String(value).trim() } +// Resolve a step value that may be a quoted literal or a placeholder. +function resolveText (value, example) { + const trimmed = String(value).trim() + if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1, -1) + } + return resolveParam(value, example) +} + // Look up a post that has been loaded onto one of the read-only pages. function findDisplayedPost (txid, world) { const fromThread = world.threadPage.getPost(txid) @@ -2204,6 +2233,165 @@ const handlers = [ throw new Error(`Expected no profiles in search results, got ${world.searchPage.profiles.length}.`) } } + }, + { + name: 'wallet follows address', + pattern: /^my wallet follows the address (.+)$/, + run (m, example, world) { + const followee = resolveParam(m[1], example) + const myAddr = world.wallet.walletInfo.cashAddress + world.memoDb.setFollowState(myAddr, followee, true) + } + }, + { + name: 'API serves post with txid address text and block height', + pattern: /^the psf-memo-db API serves a post with txid (.+) authored by the address (.+) with text (.+) at block height (\d+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const addr = resolveParam(m[2], example) + const text = resolveText(m[3], example) + const blockHeight = parseInt(m[4], 10) + world.memoDb.addPost({ txid, addr, text, blockHeight }) + } + }, + { + name: 'API serves post with txid address and text', + pattern: /^the psf-memo-db API serves a post with txid (.+) authored by the address (.+) with text (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const addr = resolveParam(m[2], example) + const text = resolveText(m[3], example) + world.memoDb.addPost({ txid, addr, text, blockHeight: 100 }) + } + }, + { + name: 'API serves post with txid my address and text', + pattern: /^the psf-memo-db API serves a post with txid (.+) authored by my wallet address with text (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const myAddr = world.wallet.walletInfo.cashAddress + const text = resolveText(m[2], example) + world.memoDb.addPost({ txid, addr: myAddr, text, blockHeight: 100 }) + } + }, + { + name: 'API serves reply with txid parent and text', + pattern: /^the psf-memo-db API serves a reply with txid (.+) to the post with txid (.+) with text (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const parentTxid = resolveParam(m[2], example) + const text = resolveText(m[3], example) + world.memoDb.addReply({ txid, parentTxid, text, addr: 'bitcoincash:reply-author', blockHeight: 100 }) + } + }, + { + name: 'follow no one', + pattern: /^I follow no one$/, + run (m, example, world) { + const myAddr = world.wallet.walletInfo.cashAddress + for (const key of Object.keys(world.memoDb.followState)) { + if (key.startsWith(`${myAddr}|`)) { + world.memoDb.followState[key] = false + } + } + } + }, + { + name: 'open following feed', + pattern: /^I open the Following feed$/, + async run (m, example, world) { + await world.followingFeedPage.load() + world.currentPath = FollowingFeedPage.FOLLOWING_FEED_PATH + } + }, + { + name: 'open following feed with page size', + pattern: /^I open the Following feed with page size (\d+)$/, + async run (m, example, world) { + const limit = parseInt(m[1], 10) + await world.followingFeedPage.load({ limit }) + world.currentPath = FollowingFeedPage.FOLLOWING_FEED_PATH + } + }, + { + name: 'feed shows post with text', + pattern: /^the feed shows the post with text (.+)$/, + run (m, example, world) { + const expected = resolveText(m[1], example) + const found = world.followingFeedPage.posts.find((p) => p.text === expected) + if (!found) { + throw new Error(`Following feed does not show a post with text "${expected}".`) + } + } + }, + { + name: 'feed does not show post with text', + pattern: /^the feed does not show the post with text (.+)$/, + run (m, example, world) { + const expected = resolveText(m[1], example) + const found = world.followingFeedPage.posts.find((p) => p.text === expected) + if (found) { + throw new Error(`Following feed unexpectedly shows a post with text "${expected}".`) + } + } + }, + { + name: 'feed shows post txid before txid', + pattern: /^the feed shows the post with txid (.+) before the post with txid (.+)$/, + run (m, example, world) { + const firstTxid = resolveParam(m[1], example) + const secondTxid = resolveParam(m[2], example) + const posts = world.followingFeedPage.posts + const firstIndex = posts.findIndex((p) => p.txid === firstTxid) + const secondIndex = posts.findIndex((p) => p.txid === secondTxid) + if (firstIndex === -1) { + throw new Error(`Following feed does not show post ${firstTxid}.`) + } + if (secondIndex === -1) { + throw new Error(`Following feed does not show post ${secondTxid}.`) + } + if (firstIndex >= secondIndex) { + throw new Error(`Expected post ${firstTxid} before ${secondTxid}, but found at indices ${firstIndex}, ${secondIndex}.`) + } + } + }, + { + name: 'feed shows N posts', + pattern: /^the feed shows (\d+) posts$/, + run (m, example, world) { + const expected = parseInt(m[1], 10) + const actual = world.followingFeedPage.posts.length + if (actual !== expected) { + throw new Error(`Expected ${expected} posts in following feed, got ${actual}.`) + } + } + }, + { + name: 'feed can load more posts', + pattern: /^the feed can load more posts$/, + run (m, example, world) { + if (!world.followingFeedPage.canLoadMore()) { + throw new Error('Expected following feed to have more posts, but pagination says there are none.') + } + } + }, + { + name: 'feed shows no posts', + pattern: /^the feed shows no posts$/, + run (m, example, world) { + if (world.followingFeedPage.posts.length !== 0) { + throw new Error(`Expected no posts in following feed, got ${world.followingFeedPage.posts.length}.`) + } + } + }, + { + name: 'feed shows not following anyone message', + pattern: /^the feed shows a message that I am not following anyone$/, + run (m, example, world) { + if (!world.followingFeedPage.emptyBecauseNoFollows) { + throw new Error('Expected following feed to show the not-following-anyone message.') + } + } } ] diff --git a/psf-memo-client/src/components/app-body/following-feed/index.js b/psf-memo-client/src/components/app-body/following-feed/index.js new file mode 100644 index 0000000..dd9bae3 --- /dev/null +++ b/psf-memo-client/src/components/app-body/following-feed/index.js @@ -0,0 +1,180 @@ +/* + Display the Following feed: top-level posts from profiles the viewer follows. +*/ + +// Global npm libraries +import React, { useState, useEffect } from 'react' +import { Container, Row, Col, Spinner, Button } from 'react-bootstrap' + +// Local libraries +import MemoDb from '../../../services/memo-db' +import FollowingFeedPage from '../../../services/following-feed-page' +import PostFeedItem from '../../post-feed/post-feed-item' +import PostThreadModal from '../../post-thread-modal' +import { + collectPostAddrs, + loadThreadProfiles +} from '../../post-thread-modal/thread-profiles' +import '../../../App.css' +import '../../post-feed/post-feed.css' + +const PAGE_SIZE = 100 + +function FollowingFeed (props) { + const { appData } = props + const wallet = appData?.wallet + + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [posts, setPosts] = useState([]) + const [profiles, setProfiles] = useState({}) + const [pagination, setPagination] = useState(null) + const [offset, setOffset] = useState(0) + const [threadTxid, setThreadTxid] = useState(null) + const [showThreadModal, setShowThreadModal] = useState(false) + const [emptyBecauseNoFollows, setEmptyBecauseNoFollows] = useState(false) + + const openThread = (txid) => { + setThreadTxid(txid) + setShowThreadModal(true) + } + + const closeThread = () => { + setShowThreadModal(false) + setThreadTxid(null) + } + + useEffect(() => { + const loadFeed = async () => { + setLoading(true) + setError(null) + setProfiles({}) + setEmptyBecauseNoFollows(false) + + try { + const memoDb = new MemoDb() + const page = new FollowingFeedPage({ memoDb, wallet }) + const data = await page.load({ limit: PAGE_SIZE, offset }) + + const loadedPosts = data.posts || [] + const addrs = collectPostAddrs(loadedPosts) + const profileMap = await loadThreadProfiles(addrs, memoDb) + + setPosts(loadedPosts) + setProfiles(profileMap) + setPagination(data.pagination || null) + setEmptyBecauseNoFollows(data.emptyBecauseNoFollows === true) + } catch (err) { + setError(err.message || 'Failed to load following feed') + setPosts([]) + setProfiles({}) + setPagination(null) + setEmptyBecauseNoFollows(false) + } + + setLoading(false) + } + + loadFeed() + }, [offset, wallet]) + + const canGoBack = offset > 0 + const canGoNext = pagination?.hasMore ?? false + + const handlePrevious = () => { + setOffset((prev) => Math.max(0, prev - PAGE_SIZE)) + } + + const handleNext = () => { + setOffset((prev) => prev + PAGE_SIZE) + } + + return ( + + + +
+

Following

+

Posts from profiles you follow.

+ + {pagination && posts.length > 0 && ( + + Showing {pagination.offset + 1}– + {pagination.offset + posts.length} of {pagination.total} + + )} +
+ + {error && ( +

+ {error} +

+ )} + + {loading && ( +
+ + + Loading... + + +
+ )} + + {!loading && !error && posts.length === 0 && ( +

+ {emptyBecauseNoFollows + ? 'You are not following anyone.' + : 'No posts from profiles you follow.'} +

+ )} + + {!loading && !error && posts.length > 0 && ( +
+ {posts.map((post) => ( + openThread(post.txid)} + showFooterMeta + /> + ))} +
+ )} + + {!loading && !error && (pagination || offset > 0) && ( +
+ + + +
+ )} + +
+ + +
+ ) +} + +export default FollowingFeed diff --git a/psf-memo-client/src/components/app-body/index.js b/psf-memo-client/src/components/app-body/index.js index 884009c..d879bbc 100644 --- a/psf-memo-client/src/components/app-body/index.js +++ b/psf-memo-client/src/components/app-body/index.js @@ -34,6 +34,7 @@ import Account from './account' import Topics from './topics' import TopicFeed from './topic-feed' import Search from './search' +import FollowingFeed from './following-feed' function AppBody (props) { // Dependency injection through props @@ -54,6 +55,7 @@ function AppBody (props) { } /> } /> } /> + } /> } /> } /> } /> diff --git a/psf-memo-client/src/components/nav-menu/index.js b/psf-memo-client/src/components/nav-menu/index.js index 7baa082..4d42093 100644 --- a/psf-memo-client/src/components/nav-menu/index.js +++ b/psf-memo-client/src/components/nav-menu/index.js @@ -86,6 +86,14 @@ function NavMenu (props) { Search + + Following + + post.txid === txid) || null + } +} + +FollowingFeedPage.FOLLOWING_FEED_PATH = FOLLOWING_FEED_PATH + +module.exports = FollowingFeedPage diff --git a/psf-memo-client/src/services/memo-db.js b/psf-memo-client/src/services/memo-db.js index 02f7602..9813888 100644 --- a/psf-memo-client/src/services/memo-db.js +++ b/psf-memo-client/src/services/memo-db.js @@ -153,6 +153,10 @@ class MemoDb { throw err } } + + async getFollowingFeed (addr, opts = {}) { + return this.getPage(`/posts/following/${encodeURIComponent(addr)}`, 'getFollowingFeed', opts) + } } export default MemoDb diff --git a/psf-memo-client/test/unit/following-feed-page.test.js b/psf-memo-client/test/unit/following-feed-page.test.js new file mode 100644 index 0000000..9329145 --- /dev/null +++ b/psf-memo-client/test/unit/following-feed-page.test.js @@ -0,0 +1,145 @@ +/* + Unit tests for the following feed page controller. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const FollowingFeedPage = require('../../src/services/following-feed-page') + +const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' + +function makeWallet () { + return { + walletInfo: { cashAddress: MY_ADDRESS } + } +} + +function makeMemoDb (posts, pagination) { + return { + async getFollowingFeed (addr, { limit, offset }) { + return { posts, pagination } + } + } +} + +test('load returns posts from the following feed', async () => { + const posts = [ + { txid: 'a'.repeat(64), text: 'hello from followee' }, + { txid: 'b'.repeat(64), text: 'another post' } + ] + const page = new FollowingFeedPage({ + memoDb: makeMemoDb(posts, { total: 2 }), + wallet: makeWallet() + }) + + const result = await page.load() + + assert.deepEqual(result.posts, posts) + assert.equal(result.pagination.total, 2) + assert.equal(result.emptyBecauseNoFollows, false) +}) + +test('load marks empty feed at offset zero as no-follows state', async () => { + const page = new FollowingFeedPage({ + memoDb: makeMemoDb([], { total: 0 }), + wallet: makeWallet() + }) + + const result = await page.load() + + assert.deepEqual(result.posts, []) + assert.equal(result.emptyBecauseNoFollows, true) +}) + +test('load does not mark empty paginated page as no-follows state', async () => { + const page = new FollowingFeedPage({ + memoDb: makeMemoDb([], { total: 2 }), + wallet: makeWallet() + }) + + const result = await page.load({ offset: 100 }) + + assert.equal(result.emptyBecauseNoFollows, false) +}) + +test('getPost returns a loaded post by txid', async () => { + const posts = [{ txid: 'a'.repeat(64), text: 'hello' }] + const page = new FollowingFeedPage({ + memoDb: makeMemoDb(posts, {}), + wallet: makeWallet() + }) + + await page.load() + + assert.equal(page.getPost('a'.repeat(64)).text, 'hello') +}) + +test('load forwards limit and offset to the memo db client', async () => { + const calls = [] + const memoDb = { + async getFollowingFeed (addr, params) { + calls.push({ addr, params }) + return { posts: [], pagination: {} } + } + } + const page = new FollowingFeedPage({ memoDb, wallet: makeWallet() }) + + await page.load({ limit: 10, offset: 20 }) + + assert.deepEqual(calls, [{ addr: MY_ADDRESS, params: { limit: 10, offset: 20 } }]) +}) + +test('load defaults limit to 100 and offset to 0', async () => { + const calls = [] + const memoDb = { + async getFollowingFeed (addr, params) { + calls.push({ addr, params }) + return { posts: [], pagination: {} } + } + } + const page = new FollowingFeedPage({ memoDb, wallet: makeWallet() }) + + await page.load() + + assert.deepEqual(calls, [{ addr: MY_ADDRESS, params: { limit: 100, offset: 0 } }]) +}) + +test('load throws when no memo db client is provided', async () => { + const page = new FollowingFeedPage({ wallet: makeWallet() }) + + await assert.rejects( + () => page.load(), + /requires a memo db client/ + ) +}) + +test('load throws when no wallet is provided', async () => { + const page = new FollowingFeedPage({ memoDb: makeMemoDb([], {}) }) + + await assert.rejects( + () => page.load(), + /requires an authenticated wallet/ + ) +}) + +test('canLoadMore reflects pagination.hasMore', async () => { + const pageMore = new FollowingFeedPage({ + memoDb: makeMemoDb([], { hasMore: true }), + wallet: makeWallet() + }) + await pageMore.load() + assert.equal(pageMore.canLoadMore(), true) + + const pageDone = new FollowingFeedPage({ + memoDb: makeMemoDb([], { hasMore: false }), + wallet: makeWallet() + }) + await pageDone.load() + assert.equal(pageDone.canLoadMore(), false) +}) + +test('exposes the following feed path', () => { + assert.equal(FollowingFeedPage.FOLLOWING_FEED_PATH, '/posts/following') +}) diff --git a/psf-memo-db/src/adapters/post-query.js b/psf-memo-db/src/adapters/post-query.js index b718d68..d515f1e 100644 --- a/psf-memo-db/src/adapters/post-query.js +++ b/psf-memo-db/src/adapters/post-query.js @@ -56,6 +56,7 @@ class PostQuery { this.topLevelPostTxids = this.topLevelPostTxids.bind(this) this.loadReplyTxids = this.loadReplyTxids.bind(this) this.isReply = this.isReply.bind(this) + this.scanFollowingFeedTxidsAndCount = this.scanFollowingFeedTxidsAndCount.bind(this) } static padHeight (height) { @@ -306,6 +307,39 @@ class PostQuery { return count } + + // Iterate the global postHeights index newest first, returning only top-level + // posts (replies excluded) authored by addresses the viewer follows, excluding + // the viewer's own posts. Returns both the page txids and total matching count. + async scanFollowingFeedTxidsAndCount (viewerAddr, followingAddrs, { limit, offset }) { + const followeeSet = new Set(followingAddrs.filter((addr) => addr !== viewerAddr)) + const replyTxids = await this.loadReplyTxids() + const txids = [] + let skipped = 0 + let total = 0 + + for await (const [key, value] of this.postHeightsDb.iterator({ reverse: true })) { + const txid = this.txidFromPostHeight(key, value) + if (replyTxids.has(txid)) continue + + const post = await this.getPostOrNull(txid) + if (!post) continue + if (!followeeSet.has(post.addr)) continue + + total++ + + if (skipped < offset) { + skipped++ + continue + } + + if (txids.length < limit) { + txids.push(txid) + } + } + + return { txids, total } + } } export default PostQuery 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 51e7726..451fc98 100644 --- a/psf-memo-db/src/controllers/rest-api/posts/controller.js +++ b/psf-memo-db/src/controllers/rest-api/posts/controller.js @@ -17,6 +17,7 @@ class PostsRESTControllerLib { this.getRecentPosts = this.getRecentPosts.bind(this) this.getPostsByAddr = this.getPostsByAddr.bind(this) + this.getFollowingFeed = this.getFollowingFeed.bind(this) this.getPostThread = this.getPostThread.bind(this) this.handleError = this.handleError.bind(this) } @@ -100,6 +101,41 @@ class PostsRESTControllerLib { } } + /** + * @api {get} /posts/following/:addr List posts from followed profiles + * @apiPermission public + * @apiName GetFollowingFeed + * @apiGroup REST Posts + * + * @apiDescription Returns top-level posts from profiles the viewer follows (replies and the viewer's own posts excluded), sorted by block height (newest first). + * + * @apiParam {String} addr Viewer cash address + * @apiQuery {Number} [limit=100] Page size (max 100) + * @apiQuery {Number} [offset=0] Number of posts to skip after sorting + * + * @apiExample Example usage: + * curl -X GET "localhost:5021/posts/following/bitcoincash:q...?limit=50&offset=0" + * + * @apiSuccess {Object[]} posts Array of post objects + * @apiSuccess {String} posts.txid Post transaction id + * @apiSuccess {String} posts.addr Author cash address + * @apiSuccess {String} posts.text Post text + * @apiSuccess {Number} posts.seen Unix epoch milliseconds + * @apiSuccess {Number} posts.blockHeight Block height when indexed + * @apiSuccess {Number} posts.replyCount Number of replies to this post + * @apiSuccess {Number} posts.likeCount Number of likes for this post + * @apiSuccess {Object} pagination Pagination metadata + */ + async getFollowingFeed (ctx) { + try { + const { addr } = ctx.params + const { limit, offset } = ctx.query + ctx.body = await this.useCases.listFollowingFeed.execute({ addr, limit, offset }) + } catch (err) { + this.handleError(ctx, err) + } + } + async getPostThread (ctx) { try { const { txid } = ctx.params diff --git a/psf-memo-db/src/controllers/rest-api/posts/index.js b/psf-memo-db/src/controllers/rest-api/posts/index.js index 88c35e0..713e087 100644 --- a/psf-memo-db/src/controllers/rest-api/posts/index.js +++ b/psf-memo-db/src/controllers/rest-api/posts/index.js @@ -26,6 +26,7 @@ class PostsRouter { attach (app) { this.router.get('/recent', this.postsRESTController.getRecentPosts) this.router.get('/by/:addr', this.postsRESTController.getPostsByAddr) + this.router.get('/following/:addr', this.postsRESTController.getFollowingFeed) this.router.get('/:txid/thread', this.postsRESTController.getPostThread) app.use(this.router.routes()) app.use(this.router.allowedMethods()) diff --git a/psf-memo-db/src/use-cases/index.js b/psf-memo-db/src/use-cases/index.js index 2022e09..61ff709 100644 --- a/psf-memo-db/src/use-cases/index.js +++ b/psf-memo-db/src/use-cases/index.js @@ -5,6 +5,7 @@ import ListRecentProfiles from './list-recent-profiles.js' import ListRecentPosts from './list-recent-posts.js' import ListPostsByAddr from './list-posts-by-addr.js' +import ListFollowingFeed from './list-following-feed.js' import GetPostThread from './get-post-thread.js' import FollowState from './follow-state.js' import ListFollowing from './list-following.js' @@ -33,6 +34,7 @@ class UseCases { this.listRecentProfiles = null this.listRecentPosts = null this.listPostsByAddr = null + this.listFollowingFeed = null this.getPostThread = null this.followState = null this.listFollowing = null @@ -62,6 +64,10 @@ class UseCases { adapters: this.adapters }) + this.listFollowingFeed = new ListFollowingFeed({ + adapters: this.adapters + }) + this.getPostThread = new GetPostThread({ adapters: this.adapters }) diff --git a/psf-memo-db/src/use-cases/list-following-feed.js b/psf-memo-db/src/use-cases/list-following-feed.js new file mode 100644 index 0000000..9d3ea30 --- /dev/null +++ b/psf-memo-db/src/use-cases/list-following-feed.js @@ -0,0 +1,55 @@ +/* + Use case: list top-level posts from profiles the viewer follows, newest first. + + Joins the follows index with the global postHeights index. Replies and the + viewer's own posts are excluded. Results are paginated with limit/offset. +*/ + +import { parseLimit, parseOffset, assemblePostPage } from './lib/pagination.js' + +class ListFollowingFeed { + constructor (localConfig = {}) { + this.adapters = localConfig.adapters + if (!this.adapters) { + throw new Error('Adapters required when instantiating ListFollowingFeed use case.') + } + if (!this.adapters.postQuery) { + throw new Error('postQuery adapter required for ListFollowingFeed use case.') + } + if (!this.adapters.followQuery) { + throw new Error('followQuery adapter required for ListFollowingFeed use case.') + } + this.execute = this.execute.bind(this) + } + + parseAddr (addr) { + if (!addr || typeof addr !== 'string') { + const err = new Error('addr is required') + err.status = 400 + throw err + } + return addr + } + + async execute (inObj = {}) { + const addr = this.parseAddr(inObj.addr) + const limit = parseLimit(inObj.limit) + const offset = parseOffset(inObj.offset) + + const followingAddrs = await this.adapters.followQuery.listFollowing(addr) + const { txids, total } = await this.adapters.postQuery.scanFollowingFeedTxidsAndCount( + addr, + followingAddrs, + { limit, offset } + ) + const [posts, replyCounts, likeCounts] = await Promise.all([ + this.adapters.postQuery.loadPostsByTxids(txids), + this.adapters.postQuery.countRepliesForTxids(txids), + this.adapters.postQuery.countLikesForTxids(txids) + ]) + + return assemblePostPage({ posts, replyCounts, likeCounts, total, limit, offset }) + } +} + +export default ListFollowingFeed 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 a7c678b..00d0b75 100644 --- a/psf-memo-db/test/unit/adapters/post-query.unit.js +++ b/psf-memo-db/test/unit/adapters/post-query.unit.js @@ -270,6 +270,110 @@ describe('#PostQuery', () => { }) }) + describe('#scanFollowingFeedTxidsAndCount', () => { + const viewerAddr = 'bitcoincash:viewer' + const followeeA = 'bitcoincash:followee-a' + const followeeB = 'bitcoincash:followee-b' + + it('should return posts only from followed addresses excluding the viewer', async () => { + async function * mockHeights () { + yield ['000000600300:post-a', { txid: 'post-a' }] + yield ['000000600250:post-viewer', { txid: 'post-viewer' }] + yield ['000000600200:post-b', { txid: 'post-b' }] + yield ['000000600100:post-other', { txid: 'post-other' }] + } + postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights()) + postsDb.get.callsFake(async (txid) => { + const map = { + 'post-a': { addr: followeeA, text: 'a' }, + 'post-viewer': { addr: viewerAddr, text: 'mine' }, + 'post-b': { addr: followeeB, text: 'b' }, + 'post-other': { addr: 'bitcoincash:other', text: 'other' } + } + return map[txid] + }) + + const result = await uut.scanFollowingFeedTxidsAndCount( + viewerAddr, + [followeeA, followeeB], + { limit: 10, offset: 0 } + ) + + assert.deepEqual(result.txids, ['post-a', 'post-b']) + assert.equal(result.total, 2) + }) + + it('should exclude replies from the following feed', async () => { + async function * mockParents () { + yield ['reply-a', { parentTxid: 'post-a', childTxid: 'reply-a' }] + } + async function * mockHeights () { + yield ['000000600300:reply-a', { txid: 'reply-a' }] + yield ['000000600200:post-a', { txid: 'post-a' }] + } + postParentsDb.iterator.returns(mockParents()) + postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights()) + postsDb.get.callsFake(async (txid) => { + return { addr: followeeA, text: txid } + }) + + const result = await uut.scanFollowingFeedTxidsAndCount( + viewerAddr, + [followeeA], + { limit: 10, offset: 0 } + ) + + assert.deepEqual(result.txids, ['post-a']) + assert.equal(result.total, 1) + }) + + it('should apply limit and offset', async () => { + async function * mockHeights () { + yield ['000000600400:post-a', { txid: 'post-a' }] + yield ['000000600300:post-b', { txid: 'post-b' }] + yield ['000000600200:post-c', { txid: 'post-c' }] + } + postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights()) + postsDb.get.callsFake(async (txid) => { + return { addr: followeeA, text: txid } + }) + + const result = await uut.scanFollowingFeedTxidsAndCount( + viewerAddr, + [followeeA], + { limit: 1, offset: 1 } + ) + + assert.deepEqual(result.txids, ['post-b']) + assert.equal(result.total, 3) + }) + + it('should skip missing posts', async () => { + async function * mockHeights () { + yield ['000000600200:post-a', { txid: 'post-a' }] + yield ['000000600100:missing', { txid: 'missing' }] + } + postHeightsDb.iterator.withArgs({ reverse: true }).returns(mockHeights()) + postsDb.get.callsFake(async (txid) => { + if (txid === 'missing') { + const err = new Error('not found') + err.notFound = true + throw err + } + return { addr: followeeA, text: txid } + }) + + const result = await uut.scanFollowingFeedTxidsAndCount( + viewerAddr, + [followeeA], + { limit: 10, offset: 0 } + ) + + assert.deepEqual(result.txids, ['post-a']) + assert.equal(result.total, 1) + }) + }) + describe('#isReply', () => { it('should return true when the txid has a parent post', async () => { postParentsDb.get.withArgs('reply-1').resolves({ parentTxid: 'parent-1' }) diff --git a/psf-memo-db/test/unit/controllers/posts.controller.unit.js b/psf-memo-db/test/unit/controllers/posts.controller.unit.js index d148356..3bfbb89 100644 --- a/psf-memo-db/test/unit/controllers/posts.controller.unit.js +++ b/psf-memo-db/test/unit/controllers/posts.controller.unit.js @@ -22,6 +22,12 @@ describe('#PostsRESTController', () => { posts: [{ txid: 'tx2', addr: 'addr-a', blockHeight: 600100 }], pagination: { limit: 100, offset: 0, total: 1, hasMore: false } }) + }, + listFollowingFeed: { + execute: sandbox.stub().resolves({ + posts: [{ txid: 'tx3', addr: 'addr-b', blockHeight: 600200 }], + pagination: { limit: 100, offset: 0, total: 1, hasMore: false } + }) } } }) @@ -60,4 +66,23 @@ describe('#PostsRESTController', () => { assert.equal(ctx.body.posts.length, 1) assert.equal(ctx.body.posts[0].txid, 'tx2') }) + + it('should return following feed from use case', async () => { + const ctx = { + params: { addr: 'addr-b' }, + query: { limit: '25', offset: '0' }, + body: null, + throw: sandbox.stub() + } + await uut.getFollowingFeed(ctx) + + assert.equal(uut.useCases.listFollowingFeed.execute.callCount, 1) + assert.deepEqual(uut.useCases.listFollowingFeed.execute.firstCall.args[0], { + addr: 'addr-b', + limit: '25', + offset: '0' + }) + assert.equal(ctx.body.posts.length, 1) + assert.equal(ctx.body.posts[0].txid, 'tx3') + }) }) diff --git a/psf-memo-db/test/unit/use-cases/list-following-feed.unit.js b/psf-memo-db/test/unit/use-cases/list-following-feed.unit.js new file mode 100644 index 0000000..4a0d9c5 --- /dev/null +++ b/psf-memo-db/test/unit/use-cases/list-following-feed.unit.js @@ -0,0 +1,116 @@ +import { assert } from 'chai' +import sinon from 'sinon' +import ListFollowingFeed from '../../../src/use-cases/list-following-feed.js' + +describe('#ListFollowingFeed', () => { + let uut + let sandbox + let postQuery + let followQuery + + const viewerAddr = 'bitcoincash:viewer' + const followeeAddr = 'bitcoincash:followee' + const otherAddr = 'bitcoincash:other' + + const mockPosts = { + 'tx-a': { addr: followeeAddr, text: 'a', seen: 100, blockHeight: 600100 }, + 'tx-b': { addr: followeeAddr, text: 'b', seen: 200, blockHeight: 600200 }, + 'tx-c': { addr: otherAddr, text: 'c', seen: 50, blockHeight: 600300 } + } + + beforeEach(() => { + sandbox = sinon.createSandbox() + postQuery = { + scanFollowingFeedTxidsAndCount: sandbox.stub().callsFake(async (viewer, followees, { limit, offset }) => { + const all = Object.entries(mockPosts) + .filter(([txid, post]) => followees.includes(post.addr) && post.addr !== viewer) + .sort((a, b) => b[1].blockHeight - a[1].blockHeight) + .map(([txid]) => txid) + return { + txids: all.slice(offset, offset + limit), + total: all.length + } + }), + loadPostsByTxids: sandbox.stub().callsFake(async (txids) => { + return txids.map((txid) => ({ txid, ...mockPosts[txid] })) + }), + countRepliesForTxids: sandbox.stub().resolves(new Map()), + countLikesForTxids: sandbox.stub().resolves(new Map([['tx-a', 2]])) + } + followQuery = { + listFollowing: sandbox.stub().resolves([followeeAddr]) + } + uut = new ListFollowingFeed({ + adapters: { postQuery, followQuery } + }) + }) + + afterEach(() => sandbox.restore()) + + it('should return posts from followed addresses sorted by block height descending', async () => { + const result = await uut.execute({ addr: viewerAddr, limit: 10, offset: 0 }) + + assert.equal(result.posts.length, 2) + assert.equal(result.posts[0].txid, 'tx-b') + assert.equal(result.posts[1].txid, 'tx-a') + assert.equal(result.posts[1].likeCount, 2) + assert.equal(result.pagination.total, 2) + assert.equal(result.pagination.hasMore, false) + }) + + it('should list who the viewer follows', async () => { + await uut.execute({ addr: viewerAddr }) + + assert.equal(followQuery.listFollowing.calledOnce, true) + assert.equal(followQuery.listFollowing.firstCall.args[0], viewerAddr) + }) + + it('should reject missing addr', async () => { + try { + await uut.execute({ limit: 10 }) + assert.fail('Expected error') + } catch (err) { + assert.equal(err.status, 400) + assert.include(err.message, 'addr is required') + } + }) + + it('should reject a non-string addr', async () => { + try { + await uut.execute({ addr: 12345, limit: 10 }) + assert.fail('Expected error') + } catch (err) { + assert.equal(err.status, 400) + assert.include(err.message, 'addr is required') + } + }) + + it('should pass addr, limit, and offset to postQuery', async () => { + await uut.execute({ addr: viewerAddr, limit: 5, offset: 10 }) + + assert.equal(postQuery.scanFollowingFeedTxidsAndCount.calledOnce, true) + assert.equal(postQuery.scanFollowingFeedTxidsAndCount.firstCall.args[0], viewerAddr) + assert.deepEqual(postQuery.scanFollowingFeedTxidsAndCount.firstCall.args[1], [followeeAddr]) + assert.deepEqual(postQuery.scanFollowingFeedTxidsAndCount.firstCall.args[2], { limit: 5, offset: 10 }) + }) + + it('should require the postQuery adapter', () => { + try { + // eslint-disable-next-line no-new + new ListFollowingFeed({ adapters: { followQuery } }) + assert.fail('Expected error') + } catch (err) { + assert.include(err.message, 'postQuery adapter required') + } + }) + + it('should require the followQuery adapter', () => { + try { + // eslint-disable-next-line no-new + new ListFollowingFeed({ adapters: { postQuery } }) + assert.fail('Expected error') + } catch (err) { + assert.include(err.message, 'followQuery adapter required') + } + }) +})