Merge Following feed (P6.6) from refactorer

This commit is contained in:
Chris Troutner
2026-08-29 10:44:54 -07:00
20 changed files with 1429 additions and 37 deletions
+190 -2
View File
@@ -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 <parameter> 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.')
}
}
}
]
@@ -0,0 +1,67 @@
# Scenarios: Following Feed - 1, Following Feed - 2, Following Feed - 3, Following Feed - 4, Following Feed - 5, Following Feed - 6, Following Feed - 7
#
# The Following feed shows top-level posts (replies excluded) authored only by
# profiles the viewer follows, newest first. The viewer's own posts are never
# shown, nor are posts from people the viewer does not follow. It is a read-only
# feature: the psf-memo-db API joins the follows index with the posts index and
# returns a paginated page. The client identifies the viewer from the wallet and
# renders the page like the recent feed; it broadcasts no Memo action.
Feature: Following Feed
Background:
Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d
Scenario Outline: Following Feed - 1 the feed shows posts from followed profiles only
Given my wallet follows the address <followee>
Given the psf-memo-db API serves a post with txid <txid_a> authored by the address <followee> with text <text_a>
Given the psf-memo-db API serves a post with txid <txid_b> authored by the address <other> with text <text_b>
When I open the Following feed
Then the feed shows the post with text <text_a>
And the feed does not show the post with text <text_b>
Examples:
| followee | txid_a | text_a | other | txid_b | text_b |
| bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy | 1111111111111111111111111111111111111111111111111111111111111111 | hello from alice | bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r | 2222222222222222222222222222222222222222222222222222222222222222 | hello from bob |
| bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r | 3333333333333333333333333333333333333333333333333333333333333333 | bitcoin rocks | bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy | 4444444444444444444444444444444444444444444444444444444444444444 | memo is fun |
Scenario: Following Feed - 2 the feed does not include the viewer's own posts
Given my wallet follows the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy
Given the psf-memo-db API serves a post with txid 1111111111111111111111111111111111111111111111111111111111111111 authored by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "hello from alice"
Given the psf-memo-db API serves a post with txid 5555555555555555555555555555555555555555555555555555555555555555 authored by my wallet address with text "my own post"
When I open the Following feed
Then the feed shows the post with text "hello from alice"
And the feed does not show the post with text "my own post"
Scenario: Following Feed - 3 the feed orders followed posts newest first
Given my wallet follows the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy
Given the psf-memo-db API serves a post with txid 0000000000000000000000000000000000000000000000000000000000000001 authored by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "older post" at block height 100
Given the psf-memo-db API serves a post with txid 0000000000000000000000000000000000000000000000000000000000000002 authored by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "newer post" at block height 300
When I open the Following feed
Then the feed shows the post with txid 0000000000000000000000000000000000000000000000000000000000000002 before the post with txid 0000000000000000000000000000000000000000000000000000000000000001
Scenario: Following Feed - 4 the feed excludes replies
Given my wallet follows the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy
Given the psf-memo-db API serves a post with txid 1111111111111111111111111111111111111111111111111111111111111111 authored by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "top level post"
Given the psf-memo-db API serves a reply with txid 6666666666666666666666666666666666666666666666666666666666666666 to the post with txid 1111111111111111111111111111111111111111111111111111111111111111 with text "a reply"
When I open the Following feed
Then the feed shows the post with text "top level post"
And the feed does not show the post with text "a reply"
Scenario: Following Feed - 5 the feed paginates followed posts
Given my wallet follows the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy
Given the psf-memo-db API serves a post with txid 1111111111111111111111111111111111111111111111111111111111111111 authored by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "post one"
Given the psf-memo-db API serves a post with txid 2222222222222222222222222222222222222222222222222222222222222222 authored by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "post two"
Given the psf-memo-db API serves a post with txid 3333333333333333333333333333333333333333333333333333333333333333 authored by the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy with text "post three"
When I open the Following feed with page size 2
Then the feed shows 2 posts
And the feed can load more posts
Scenario: Following Feed - 6 the feed shows a message when the viewer follows no one
Given I follow no one
When I open the Following feed
Then the feed shows a message that I am not following anyone
Scenario: Following Feed - 7 the feed is empty when followed profiles have no posts
Given my wallet follows the address bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy
When I open the Following feed
Then the feed shows no posts
@@ -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 (
<Container className='following-feed-page'>
<Row className='justify-content-center'>
<Col lg={8} md={10} xs={12}>
<header className='following-feed-heading'>
<h1>Following</h1>
<p>Posts from profiles you follow.</p>
{pagination && posts.length > 0 && (
<span className='following-feed-count'>
Showing {pagination.offset + 1}
{pagination.offset + posts.length} of {pagination.total}
</span>
)}
</header>
{error && (
<p className='following-feed-error'>
{error}
</p>
)}
{loading && (
<div className='text-center my-5'>
<Spinner animation='border' role='status'>
<span className='visually-hidden'>
Loading...
</span>
</Spinner>
</div>
)}
{!loading && !error && posts.length === 0 && (
<p className='following-feed-empty'>
{emptyBecauseNoFollows
? 'You are not following anyone.'
: 'No posts from profiles you follow.'}
</p>
)}
{!loading && !error && posts.length > 0 && (
<div className='posts-feed'>
{posts.map((post) => (
<PostFeedItem
key={post.txid}
post={post}
profiles={profiles}
wallet={wallet}
onReplyClick={() => openThread(post.txid)}
showFooterMeta
/>
))}
</div>
)}
{!loading && !error && (pagination || offset > 0) && (
<div className='following-feed-pagination'>
<Button
variant='outline-dark'
onClick={handlePrevious}
disabled={!canGoBack}
>
Previous
</Button>
<Button
variant='outline-dark'
onClick={handleNext}
disabled={!canGoNext}
>
Next
</Button>
</div>
)}
</Col>
</Row>
<PostThreadModal
show={showThreadModal}
txid={threadTxid}
onHide={closeThread}
wallet={wallet}
profiles={profiles}
/>
</Container>
)
}
export default FollowingFeed
@@ -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) {
<Route path='/topics' element={<Topics />} />
<Route path='/topics/:room' element={<TopicFeed appData={appData} />} />
<Route path='/search' element={<Search />} />
<Route path='/posts/following' element={<FollowingFeed appData={appData} />} />
<Route path='/memo/set-name' element={<SetName appData={appData} />} />
<Route path='/memo/set-bio' element={<SetBio appData={appData} />} />
<Route path='/memo/set-avatar-url' element={<SetAvatarUrl appData={appData} />} />
@@ -86,6 +86,14 @@ function NavMenu (props) {
Search
</NavLink>
<NavLink
className={currentPath === '/posts/following' ? 'nav-link-active' : 'nav-link-inactive'}
to='/posts/following'
onClick={handleClickEvent}
>
Following
</NavLink>
<NavLink
className={currentPath === '/posts/new' ? 'nav-link-active' : 'nav-link-inactive'}
to='/posts/new'
@@ -0,0 +1,54 @@
/*
Following Feed Page behavior: load and display top-level posts from
profiles the viewer follows.
This is the testable controller behind the React "Following" feed page. It
wraps the MemoDb client, identifies the viewer from the injected wallet, and
exposes the loaded posts so the view can render them like the recent feed.
*/
const FOLLOWING_FEED_PATH = '/posts/following'
class FollowingFeedPage {
constructor (deps = {}) {
this.memoDb = deps.memoDb || null
this.wallet = deps.wallet || null
this.posts = []
this.pagination = null
this.emptyBecauseNoFollows = false
}
getMyAddress () {
return this.wallet?.walletInfo?.cashAddress || null
}
async load ({ limit = 100, offset = 0 } = {}) {
if (!this.memoDb) {
throw new Error('Following feed page requires a memo db client.')
}
const myAddr = this.getMyAddress()
if (!myAddr) {
throw new Error('Following feed page requires an authenticated wallet.')
}
const data = await this.memoDb.getFollowingFeed(myAddr, { limit, offset })
this.posts = data.posts || []
this.pagination = data.pagination || null
this.emptyBecauseNoFollows = this.posts.length === 0 && offset === 0
return { posts: this.posts, pagination: this.pagination, emptyBecauseNoFollows: this.emptyBecauseNoFollows }
}
canLoadMore () {
return this.pagination?.hasMore ?? false
}
getPost (txid) {
return this.posts.find((post) => post.txid === txid) || null
}
}
FollowingFeedPage.FOLLOWING_FEED_PATH = FOLLOWING_FEED_PATH
module.exports = FollowingFeedPage
+4
View File
@@ -153,6 +153,10 @@ class MemoDb {
throw err
}
}
async getFollowingFeed (addr, opts = {}) {
return this.getPage(`/posts/following/${encodeURIComponent(addr)}`, 'getFollowingFeed', opts)
}
}
export default MemoDb
@@ -0,0 +1,134 @@
/*
Property tests for the FollowingFeedPage controller.
The unit tests probe a few fixed pagination shapes. These properties cover
the load/`emptyBecauseNoFollows`/`canLoadMore` invariants over broad random
inputs so they hold everywhere:
- empty state: emptyBecauseNoFollows is true exactly when no posts are
returned and the requested offset is zero.
- has more: canLoadMore always mirrors pagination.hasMore.
- forwarding: the load limits and offset forwarded to the memo-db client
match exactly what the caller requested.
- lookup: getPost returns a loaded post by txid and null otherwise.
*/
'use strict'
const test = require('node:test')
const { seededRandom, forAll, intGen } = require('./harness')
const FollowingFeedPage = require('../../src/services/following-feed-page')
const rng = seededRandom(20260902)
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const HEX = '0123456789abcdef'
function txidGen () {
let out = ''
for (let i = 0; i < 64; i++) {
out += HEX[Math.floor(rng() * HEX.length)]
}
return out
}
function makeMemoDb (posts, pagination) {
return {
async getFollowingFeed (addr, { limit, offset }) {
return { posts, pagination }
}
}
}
function fixtureGen () {
return () => {
const n = intGen(rng, 0, 5)()
const posts = []
for (let i = 0; i < n; i++) {
posts.push({ txid: txidGen(), text: 'post ' + i })
}
return {
posts,
pagination: { hasMore: rng() < 0.5 },
limit: intGen(rng, 1, 100)(),
offset: intGen(rng, 0, 200)()
}
}
}
test('emptyBecauseNoFollows is true exactly for an empty feed at offset zero', async () => {
await forAll(
fixtureGen(),
async ({ posts, pagination, limit, offset }) => {
const page = new FollowingFeedPage({
memoDb: makeMemoDb(posts, pagination),
wallet: { walletInfo: { cashAddress: MY_ADDRESS } }
})
const result = await page.load({ limit, offset })
return result.emptyBecauseNoFollows === (posts.length === 0 && offset === 0) &&
page.emptyBecauseNoFollows === (posts.length === 0 && offset === 0)
},
{ label: 'emptyBecauseNoFollows state invariant' }
)
})
test('canLoadMore always mirrors pagination.hasMore', async () => {
await forAll(
fixtureGen(),
async ({ posts, pagination, limit, offset }) => {
const page = new FollowingFeedPage({
memoDb: makeMemoDb(posts, pagination),
wallet: { walletInfo: { cashAddress: MY_ADDRESS } }
})
await page.load({ limit, offset })
return page.canLoadMore() === (pagination.hasMore === true) &&
page.pagination.hasMore === pagination.hasMore
},
{ label: 'canLoadMore mirrors hasMore' }
)
})
test('load forwards exactly the requested limit and offset to the memo-db client', async () => {
await forAll(
fixtureGen(),
async ({ posts, pagination, limit, offset }) => {
const calls = []
const memoDb = {
async getFollowingFeed (addr, params) {
calls.push({ addr, params })
return { posts, pagination }
}
}
const page = new FollowingFeedPage({ memoDb, wallet: { walletInfo: { cashAddress: MY_ADDRESS } } })
await page.load({ limit, offset })
return calls.length === 1 &&
calls[0].addr === MY_ADDRESS &&
calls[0].params.limit === limit &&
calls[0].params.offset === offset
},
{ label: 'load forwards limit and offset' }
)
})
test('getPost returns a loaded post by txid, otherwise null', async () => {
await forAll(
fixtureGen(),
async ({ posts, pagination, limit, offset }) => {
const page = new FollowingFeedPage({
memoDb: makeMemoDb(posts, pagination),
wallet: { walletInfo: { cashAddress: MY_ADDRESS } }
})
await page.load({ limit, offset })
if (posts.length === 0) return true
const any = page.getPost(posts[0].txid)
if (!any || any.text !== posts[0].text) return false
return page.getPost('0'.repeat(64)) === null
},
{ label: 'getPost lookup' }
)
})
@@ -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')
})
+34
View File
@@ -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
@@ -17,10 +17,36 @@ 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.runUseCase = this.runUseCase.bind(this)
this.listPostsForAddr = this.listPostsForAddr.bind(this)
this.handleError = this.handleError.bind(this)
}
/*
Run a use-case delegate against a Koa context, storing the resolved value
on ctx.body and routing any rejection through the shared handleError.
*/
async runUseCase (ctx, fn) {
try {
ctx.body = await fn()
} catch (err) {
this.handleError(ctx, err)
}
}
/*
Shared handler body for the addr-scoped post listings (/by/:addr and
/following/:addr): read the address route param plus pagination query
params and delegate to the given use case.
*/
async listPostsForAddr (ctx, useCase) {
const { addr } = ctx.params
const { limit, offset } = ctx.query
await this.runUseCase(ctx, () => useCase.execute({ addr, limit, offset }))
}
handleError (ctx, err) {
if (err.status) {
ctx.throw(err.status, err.message || err)
@@ -58,12 +84,8 @@ class PostsRESTControllerLib {
* @apiSuccess {Boolean} pagination.hasMore True if more pages exist
*/
async getRecentPosts (ctx) {
try {
const { limit, offset } = ctx.query
ctx.body = await this.useCases.listRecentPosts.execute({ limit, offset })
} catch (err) {
this.handleError(ctx, err)
}
const { limit, offset } = ctx.query
await this.runUseCase(ctx, () => this.useCases.listRecentPosts.execute({ limit, offset }))
}
/**
@@ -91,25 +113,43 @@ class PostsRESTControllerLib {
* @apiSuccess {Object} pagination Pagination metadata
*/
async getPostsByAddr (ctx) {
try {
const { addr } = ctx.params
const { limit, offset } = ctx.query
ctx.body = await this.useCases.listPostsByAddr.execute({ addr, limit, offset })
} catch (err) {
this.handleError(ctx, err)
}
await this.listPostsForAddr(ctx, this.useCases.listPostsByAddr)
}
/**
* @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) {
await this.listPostsForAddr(ctx, this.useCases.listFollowingFeed)
}
async getPostThread (ctx) {
try {
const { txid } = ctx.params
ctx.body = await this.useCases.getPostThread.execute({
txid
})
} catch (err) {
this.handleError(ctx, err)
}
const { txid } = ctx.params
await this.runUseCase(ctx, () => this.useCases.getPostThread.execute({
txid
}))
}
}
@@ -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())
+6
View File
@@ -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
})
@@ -0,0 +1,49 @@
/*
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'
import { ListUseCase } from './lib/use-case.js'
class ListFollowingFeed extends ListUseCase {
constructor (localConfig = {}) {
super(localConfig, { useCaseName: 'ListFollowingFeed', adapterName: 'postQuery' })
if (!this.adapters.followQuery) {
throw new Error('followQuery adapter required for ListFollowingFeed use case.')
}
}
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
@@ -0,0 +1,186 @@
/*
Property tests for the following-feed query scan.
The unit tests probe scanFollowingFeedTxidsAndCount at a few fixed
fixtures. These properties pin down invariants that hold over broad random
record sets:
- newest-first ordering: the feed is iterated by postHeights key order
reversed, so returned posts are newest first.
- reply exclusion: a reply is never returned as a top-level feed item.
- viewer exclusion: the viewer's own posts are never returned.
- membership: every returned post was authored by a followed address
(ignoring the viewer in the follow list).
- pagination conservation: applying offset/limit returns exactly the full
matching set sliced to the page, and reports an exact total.
*/
import test from 'node:test'
import { seededRandom, forAll, intGen, txidGen } from './harness.js'
import PostQuery from '../../src/adapters/post-query.js'
const rng = seededRandom(20260901)
const VIEWER = 'bitcoincash:viewer'
const ADDRS = ['bitcoincash:viewer', 'bitcoincash:f1', 'bitcoincash:f2', 'bitcoincash:o1', 'bitcoincash:o2']
// In-memory postHeights store mirroring the LevelDB iterator contract
// (reverse ordering over the padded-height key string).
function makePostHeightsDb (entries) {
const store = new Map(entries.map((e) => [e.key, e.value]))
return {
async * iterator (opts = {}) {
const { reverse = false } = opts
let keys = Array.from(store.keys()).sort()
if (reverse) keys = keys.reverse()
for (const key of keys) {
yield [key, store.get(key)]
}
}
}
}
function makePostsDb (posts) {
const store = new Map(posts.map((p) => [p.txid, p]))
return {
async get (txid) {
const post = store.get(txid)
if (!post) {
const err = new Error('not found')
err.notFound = true
throw err
}
return post
}
}
}
function makeParentsDb (replyTxids) {
return {
async * iterator () {
for (const txid of replyTxids) {
yield [txid, { parentTxid: 'parent' }]
}
}
}
}
function makeQuery (postHeightsEntries, postsDb, replyTxids) {
return new PostQuery({
postsDb: makePostsDb(postsDb),
postHeightsDb: makePostHeightsDb(postHeightsEntries),
addrPostHeightsDb: {},
postParentsDb: makeParentsDb(replyTxids),
postChildrenDb: {},
likesDb: {},
postLikesDb: {}
})
}
function fixtureGen () {
return () => {
const n = intGen(rng, 0, 14)()
const posts = [] // every post exists in the DB
const postHeights = [] // every post appears in the global index
const replyTxids = new Set()
for (let i = 0; i < n; i++) {
const txid = txidGen(rng)
const height = intGen(rng, 0, 9000000)()
const addr = ADDRS[Math.floor(rng() * ADDRS.length)]
const isReply = rng() < 0.3
const dangling = rng() < 0.15 // in index but missing from posts DB
const post = dangling
? null
: {
txid,
addr,
text: 'post ' + i,
seen: intGen(rng, 0, 1000000)(),
blockHeight: height
}
if (isReply) replyTxids.add(txid)
postHeights.push({
key: PostQuery.postHeightKey(height, txid),
value: { txid }
})
// A dangling entry contributes no post record but stays in the index.
if (!dangling) posts.push({ txid, ...post })
}
// A follow list may also include the viewer (which must be ignored).
const followed = []
const fCount = intGen(rng, 0, 6)()
for (let i = 0; i < fCount; i++) {
followed.push(ADDRS[Math.floor(rng() * ADDRS.length)])
}
return {
postHeights,
posts,
replyTxids,
followed,
limit: intGen(rng, 1, 8)(),
offset: intGen(rng, 0, 10)()
}
}
}
function buildExpected (query, posts, replyTxids, followed, { limit, offset }) {
const followeeSet = new Set(followed.filter((a) => a !== VIEWER))
const matching = posts
.filter((p) => !replyTxids.has(p.txid))
.filter((p) => followeeSet.has(p.addr))
const ordered = [...matching].sort((a, b) => {
const ka = PostQuery.postHeightKey(a.blockHeight, a.txid)
const kb = PostQuery.postHeightKey(b.blockHeight, b.txid)
return ka < kb ? 1 : ka > kb ? -1 : 0
})
return {
total: ordered.length,
txids: ordered.slice(offset, offset + limit).map((p) => p.txid)
}
}
test('following-feed scan returns followed top-level posts newest first with an exact total', async () => {
await forAll(
fixtureGen(),
async ({ postHeights, posts, replyTxids, followed, limit, offset }) => {
const query = makeQuery(postHeights, posts, replyTxids)
const { txids, total } = await query.scanFollowingFeedTxidsAndCount(
VIEWER,
followed,
{ limit, offset }
)
const expected = buildExpected(query, posts, replyTxids, followed, { limit, offset })
return total === expected.total && JSON.stringify(txids) === JSON.stringify(expected.txids)
},
{ label: 'following-feed pagination conservation and ordering' }
)
})
test('following-feed scan never returns replies, the viewer, or un-followed authors', async () => {
await forAll(
fixtureGen(),
async ({ postHeights, posts, replyTxids, followed, limit }) => {
const query = makeQuery(postHeights, posts, replyTxids)
const { txids } = await query.scanFollowingFeedTxidsAndCount(VIEWER, followed, { limit, offset: 0 })
const postByTxid = new Map(posts.map((p) => [p.txid, p]))
const followeeSet = new Set(followed.filter((a) => a !== VIEWER))
return txids.every((txid) => {
const post = postByTxid.get(txid)
if (!post) return false
if (replyTxids.has(txid)) return false
if (post.addr === VIEWER) return false
return followeeSet.has(post.addr)
})
},
{ label: 'following-feed reply/viewer/membership exclusion' }
)
})
@@ -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' })
@@ -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,73 @@ 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')
})
it('should map a use-case error with a status to that status', async () => {
uut.useCases.listFollowingFeed.execute = sandbox.stub().rejects(
Object.assign(new Error('boom'), { status: 400 })
)
const ctx = {
params: { addr: 'addr-b' },
query: {},
body: null,
throw: sandbox.stub()
}
await uut.getFollowingFeed(ctx)
assert.equal(ctx.body, null)
assert.equal(ctx.throw.callCount, 1)
assert.equal(ctx.throw.firstCall.args[0], 400)
assert.include(ctx.throw.firstCall.args[1], 'boom')
})
it('should map an unknown use-case error to a 500', async () => {
uut.useCases.listFollowingFeed.execute = sandbox.stub().rejects(new Error('boom'))
const ctx = {
params: { addr: 'addr-b' },
query: {},
body: null,
throw: sandbox.stub()
}
await uut.getFollowingFeed(ctx)
assert.equal(ctx.body, null)
assert.equal(ctx.throw.callCount, 1)
assert.equal(ctx.throw.firstCall.args[0], 500)
assert.include(ctx.throw.firstCall.args[1], 'boom')
})
it('should return a post thread from use case', async () => {
const ctx = {
params: { txid: 'tx4' },
body: null,
throw: sandbox.stub()
}
uut.useCases.getPostThread = {
execute: sandbox.stub().resolves({ txid: 'tx4', text: 'thread' })
}
await uut.getPostThread(ctx)
assert.equal(uut.useCases.getPostThread.execute.callCount, 1)
assert.deepEqual(uut.useCases.getPostThread.execute.firstCall.args[0], { txid: 'tx4' })
assert.equal(ctx.body.text, 'thread')
})
})
@@ -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')
}
})
})
+2 -2
View File
@@ -368,5 +368,5 @@ At the end of each session, update this file:
- Note the current `master` HEAD commit.
- State the next feature to work on.
Current `master` HEAD: `ac46807` (task `mute-user` — P4.1/P4.2 mute/unmute user — merged from the pipeline and verified: client build/test/lint, DB test/lint, and indexer test/lint all pass).
Next action: **spec P5.1** — Send money with memo (`0x6d24`).
Current `master` HEAD: `6d94372` (task `following-feed` — P6.6 Following Feed — spec'd and handed off to the coder. Search `20328ea`/`96956f3` was already verified.).
Next action: **await the coder → refactorer → architect pipeline for task `following-feed`**, then merge the architect branch into `master` and verify the affected components (client build/test/lint, DB test/lint). Send money (P5.1) was skipped by user decision (no clear use case). Future candidates after following-feed lands: P5.25.5 (MIP-0009 token exchange), P6.1 Repost, P6.2 Ranked feed, P6.3 Notifications, P6.5 Tags/hashtags.
+10 -11
View File
@@ -205,23 +205,22 @@ Polls require a new data model and rendering. The indexer has no handler yet.
| 6.1 | Repost a memo | C, I, D | `0x6d0b` is marked *planned* in the protocol |
| 6.2 | Ranked feed | C, D | memo.cash "ranked" post ordering |
| 6.3 | Notifications | C, D | replies / likes / follows to my posts |
| 6.4 | Search | C, D | posts / profiles / topics — spec drafted (task `search`), in pipeline |
| 6.4 | Search | C, D | posts / profiles / topics — ✅ shipped (task `search`) |
| 6.5 | Tags / hashtags | C, D | link + filter by tag |
| 6.6 | Following feed | C, D | feed filtered to followed users |
| 6.6 | Following feed | C, D | feed filtered to followed users — 🔜 in pipeline (task `following-feed`) |
---
## Suggested next spec
**Search (P6.4)** — spec drafted (task `search`), in pipeline:
- `psf-memo-db`: new `GET /search?q=&limit=&offset=` returning
`{ posts, profiles, pagination }`. A `SearchQuery` adapter scans `postsDb`
(top-level posts only, replies excluded via `postParentsDb`), `namesDb`
(profile name), and `profilesDb` (profile bio) with case-insensitive
substring matching; a `SearchAll` use case; a `/search` router + controller.
- `psf-memo-client`: a `/search` page with a search box; `MemoDb.search(q)`;
a `SearchPage` service; render matching posts like the feed and matching
profiles like recent-profiles.
**Following feed (P6.6)** 🔜 spec'd and handed off to the coder (task `following-feed`, commit `6d94372`). Following Feed is a read-only aggregation: the viewer sees top-level posts (replies excluded) authored only by profiles they follow, newest first, never their own posts.
- `psf-memo-db`: new `GET /posts/following/:addr?limit=&offset=` returning `{ posts, pagination }`, joining the follows index (`FollowQuery.listFollowing`) with the posts index. Empty following or no posts → empty result set.
- `psf-memo-client`: a "Following" nav page at `/posts/following` that reads the viewer's wallet address, calls the new endpoint (a `MemoDb.getFollowingFeed`), and renders posts like the recent feed; shows "you aren't following anyone" when following nobody.
- Spec: `psf-memo-client/specs/following-feed.feature`.
**Search (P6.4)** — ✅ shipped (task `search`), merged from the pipeline and verified (client build/test/lint, DB test/lint):
- `psf-memo-db`: `GET /search?q=&limit=&offset=` returning `{ posts, profiles, pagination }`. A `SearchQuery` adapter scans `postsDb` (top-level posts only, replies excluded via `postParentsDb`), `namesDb` (profile name), and `profilesDb` (profile bio) with case-insensitive substring matching; a `SearchAll` use case; a `/search` router + controller.
- `psf-memo-client`: a `/search` page with a search box; `MemoDb.search(q)`; a `SearchPage` service; render matching posts like the feed and matching profiles like recent-profiles.
- Empty queries and no-match queries both return an empty result set (no error).
- Spec: `psf-memo-client/specs/search.feature`.