mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Merge Following feed (P6.6) from refactorer
This commit is contained in:
@@ -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
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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.2–5.5 (MIP-0009 token exchange), P6.1 Repost, P6.2 Ranked feed, P6.3 Notifications, P6.5 Tags/hashtags.
|
||||
|
||||
+10
-11
@@ -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`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user