mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Merge Following feed into the posts page as Recent/Following tabs
The /posts/recent posts page now shows a row of two mode buttons. On first load it selects Following when the viewer's address follows at least one account (via GET /follow/following/:addr) and Recent otherwise, and switching tabs resets the feed to its first page. The /posts/following route and its navbar item are removed, so the Following feed is reached only through the Following button. Add the testable FeedTabsPage controller with unit coverage and the acceptance handlers for feed-tabs. Also drop the Feed Tabs - 4 assertion that the global Recent feed hides a followed author's post: /posts/recent is the global feed, so followed posts appear in both tabs. Approved by the user. By coder.
This commit is contained in:
@@ -36,6 +36,7 @@ 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 FeedTabsPage = require('../../src/services/feed-tabs-page')
|
||||
const ProfilePage = require('../../src/services/profile-page')
|
||||
const ThreadPage = require('../../src/services/thread-page')
|
||||
const TopicDiscoveryPage = require('../../src/services/topic-discovery-page')
|
||||
@@ -492,6 +493,15 @@ function makeMemoDb () {
|
||||
.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 } }
|
||||
},
|
||||
async getFollowing (addr) {
|
||||
const followees = []
|
||||
for (const [key, following] of Object.entries(followState)) {
|
||||
if (!following) continue
|
||||
const [follower, followee] = key.split('|')
|
||||
if (follower === addr) followees.push(followee)
|
||||
}
|
||||
return followees
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -533,6 +543,7 @@ function createWorld () {
|
||||
// Read-only page controllers backed by the fake psf-memo-db API.
|
||||
world.recentFeedPage = new RecentFeedPage({ memoDb, wallet })
|
||||
world.followingFeedPage = new FollowingFeedPage({ memoDb, wallet })
|
||||
world.feedTabsPage = new FeedTabsPage({ memoDb, wallet })
|
||||
world.notificationsPage = new NotificationsPage({ memoDb, wallet })
|
||||
world.profilePage = new ProfilePage({ memoDb })
|
||||
world.threadPage = new ThreadPage({ memoDb })
|
||||
@@ -1779,6 +1790,131 @@ const handlers = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'open posts feed',
|
||||
pattern: /^I open the posts feed$/,
|
||||
async run (m, example, world) {
|
||||
await world.feedTabsPage.open()
|
||||
world.currentPath = RecentFeedPage.RECENT_FEED_PATH
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'open posts feed with page size',
|
||||
pattern: /^I open the posts feed with page size (\d+)$/,
|
||||
async run (m, example, world) {
|
||||
await world.feedTabsPage.open({ limit: parseInt(m[1], 10) })
|
||||
world.currentPath = RecentFeedPage.RECENT_FEED_PATH
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'posts feed shows Recent and Following tabs',
|
||||
pattern: /^the posts feed shows the tabs "Recent" and "Following"$/,
|
||||
run (m, example, world) {
|
||||
const tabs = world.feedTabsPage.tabs
|
||||
if (tabs.length !== 2 || tabs[0] !== 'Recent' || tabs[1] !== 'Following') {
|
||||
throw new Error(`Expected the Recent and Following tabs, got ${JSON.stringify(tabs)}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Following tab is active',
|
||||
pattern: /^the Following tab is active$/,
|
||||
run (m, example, world) {
|
||||
if (!world.feedTabsPage.isFollowing()) {
|
||||
throw new Error('Expected the Following tab to be active.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'Recent tab is active',
|
||||
pattern: /^the Recent tab is active$/,
|
||||
run (m, example, world) {
|
||||
if (!world.feedTabsPage.isRecent()) {
|
||||
throw new Error('Expected the Recent tab to be active.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'click Recent tab',
|
||||
pattern: /^I click the Recent tab$/,
|
||||
async run (m, example, world) {
|
||||
await world.feedTabsPage.selectTab('Recent')
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'click Following tab',
|
||||
pattern: /^I click the Following tab$/,
|
||||
async run (m, example, world) {
|
||||
await world.feedTabsPage.selectTab('Following')
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'posts feed shows post text',
|
||||
pattern: /^the posts feed shows the post with text (.+)$/,
|
||||
run (m, example, world) {
|
||||
const expected = resolveText(m[1], example)
|
||||
const found = world.feedTabsPage.posts.find((p) => p.text === expected)
|
||||
if (!found) {
|
||||
throw new Error(`Posts feed does not show a post with text "${expected}".`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'posts feed does not show post text',
|
||||
pattern: /^the posts feed does not show the post with text (.+)$/,
|
||||
run (m, example, world) {
|
||||
const expected = resolveText(m[1], example)
|
||||
const found = world.feedTabsPage.posts.find((p) => p.text === expected)
|
||||
if (found) {
|
||||
throw new Error(`Posts feed unexpectedly shows a post with text "${expected}".`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'posts feed shows no posts',
|
||||
pattern: /^the posts feed shows no posts$/,
|
||||
run (m, example, world) {
|
||||
if (world.feedTabsPage.posts.length !== 0) {
|
||||
throw new Error(`Expected no posts in the posts feed, got ${world.feedTabsPage.posts.length}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'posts feed shows N posts',
|
||||
pattern: /^the posts feed shows (\d+) posts$/,
|
||||
run (m, example, world) {
|
||||
const expected = parseInt(m[1], 10)
|
||||
const actual = world.feedTabsPage.posts.length
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Expected ${expected} posts in the posts feed, got ${actual}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'posts feed shows not following anyone message',
|
||||
pattern: /^the posts feed shows a message that I am not following anyone$/,
|
||||
run (m, example, world) {
|
||||
if (!world.feedTabsPage.emptyBecauseNoFollows) {
|
||||
throw new Error('Expected the posts feed to show the not-following-anyone message.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'posts feed does not show not following anyone message',
|
||||
pattern: /^the posts feed does not show a message that I am not following anyone$/,
|
||||
run (m, example, world) {
|
||||
if (world.feedTabsPage.emptyBecauseNoFollows) {
|
||||
throw new Error('Did not expect the posts feed to show the not-following-anyone message.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'click Next page button',
|
||||
pattern: /^I click the Next page button$/,
|
||||
async run (m, example, world) {
|
||||
await world.feedTabsPage.nextPage()
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'open profile page for author',
|
||||
pattern: /^I open the profile page for the author of the post with txid (.+)$/,
|
||||
|
||||
@@ -54,7 +54,6 @@ Feature: Feed Tabs
|
||||
When I click the Recent tab
|
||||
Then the Recent tab is active
|
||||
And the posts feed shows the post with text "<other_text>"
|
||||
And the posts feed does not show the post with text "<followed_text>"
|
||||
|
||||
Examples:
|
||||
| followee | followed_txid | followed_text | other | other_txid | other_text |
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
/*
|
||||
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 = 50
|
||||
|
||||
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,7 +34,6 @@ import Account from './account'
|
||||
import Topics from './topics'
|
||||
import TopicFeed from './topic-feed'
|
||||
import Search from './search'
|
||||
import FollowingFeed from './following-feed'
|
||||
import Notifications from './notifications'
|
||||
|
||||
function AppBody (props) {
|
||||
@@ -56,7 +55,6 @@ 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='/notifications' element={<Notifications appData={appData} />} />
|
||||
<Route path='/memo/set-name' element={<SetName appData={appData} />} />
|
||||
<Route path='/memo/set-bio' element={<SetBio appData={appData} />} />
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
/*
|
||||
Display the most recent Memo posts from psf-memo-db.
|
||||
Display the recent/following Memo posts from psf-memo-db.
|
||||
|
||||
One posts page with two mode buttons: "Recent" shows the global recent feed
|
||||
and "Following" shows top-level posts from profiles the viewer follows. The
|
||||
default tab is chosen from the viewer's follow state, and switching tabs
|
||||
resets the feed to its first page.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import React, { useState, useEffect, useRef } from 'react'
|
||||
import { Container, Row, Col, Spinner, Button } from 'react-bootstrap'
|
||||
|
||||
// Local libraries
|
||||
import MemoDb from '../../../services/memo-db'
|
||||
import FeedTabsPage from '../../../services/feed-tabs-page'
|
||||
import PostFeedItem from '../../post-feed/post-feed-item'
|
||||
import PostThreadModal from '../../post-thread-modal'
|
||||
import {
|
||||
@@ -21,15 +27,35 @@ const PAGE_SIZE = 50
|
||||
|
||||
function RecentPosts (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 [mode, setMode] = useState(null)
|
||||
const [emptyBecauseNoFollows, setEmptyBecauseNoFollows] = useState(false)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [threadTxid, setThreadTxid] = useState(null)
|
||||
const [showThreadModal, setShowThreadModal] = useState(false)
|
||||
|
||||
const pageRef = useRef(null)
|
||||
|
||||
// Reflect a loaded controller page into React state, including the author
|
||||
// profiles needed by the post cards.
|
||||
const showPage = async (page) => {
|
||||
const addrs = collectPostAddrs(page.posts)
|
||||
const profileMap = await loadThreadProfiles(addrs, page.memoDb)
|
||||
|
||||
setPosts(page.posts)
|
||||
setPagination(page.pagination)
|
||||
setMode(page.mode)
|
||||
setEmptyBecauseNoFollows(page.emptyBecauseNoFollows)
|
||||
setOffset(page.offset)
|
||||
setProfiles(profileMap)
|
||||
}
|
||||
|
||||
const openThread = (txid) => {
|
||||
setThreadTxid(txid)
|
||||
setShowThreadModal(true)
|
||||
@@ -41,51 +67,65 @@ function RecentPosts (props) {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
const loadPosts = async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setProfiles({})
|
||||
|
||||
try {
|
||||
const memoDb = new MemoDb()
|
||||
const viewer = appData?.wallet?.walletInfo?.cashAddress
|
||||
const data = await memoDb.getRecentPosts({
|
||||
limit: PAGE_SIZE,
|
||||
offset,
|
||||
viewer
|
||||
})
|
||||
const page = new FeedTabsPage({ memoDb: new MemoDb(), wallet })
|
||||
pageRef.current = page
|
||||
await page.open({ limit: PAGE_SIZE, offset: 0 })
|
||||
|
||||
const loadedPosts = data.posts || []
|
||||
const addrs = collectPostAddrs(loadedPosts)
|
||||
const profileMap = await loadThreadProfiles(addrs, memoDb)
|
||||
|
||||
setPosts(loadedPosts)
|
||||
setProfiles(profileMap)
|
||||
setPagination(data.pagination || null)
|
||||
if (cancelled) return
|
||||
await showPage(page)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load recent posts')
|
||||
if (cancelled) return
|
||||
setError(err.message || 'Failed to load posts')
|
||||
setPosts([])
|
||||
setProfiles({})
|
||||
setPagination(null)
|
||||
setEmptyBecauseNoFollows(false)
|
||||
setOffset(0)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
|
||||
loadPosts()
|
||||
}, [offset])
|
||||
|
||||
return () => { cancelled = true }
|
||||
}, [wallet])
|
||||
|
||||
const runPageAction = async (action) => {
|
||||
const page = pageRef.current
|
||||
if (!page) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
await action(page)
|
||||
await showPage(page)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load posts')
|
||||
setPosts([])
|
||||
setProfiles({})
|
||||
setPagination(null)
|
||||
setEmptyBecauseNoFollows(false)
|
||||
setOffset(0)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleSelectTab = (tab) => runPageAction((page) => page.selectTab(tab))
|
||||
const handlePrevious = () => runPageAction((page) => page.previousPage())
|
||||
const handleNext = () => runPageAction((page) => page.nextPage())
|
||||
|
||||
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='recent-posts-page'>
|
||||
<Row className='justify-content-center'>
|
||||
@@ -96,6 +136,22 @@ function RecentPosts (props) {
|
||||
Recent messages published through the Memo protocol on Bitcoin Cash.
|
||||
</p>
|
||||
|
||||
<div className='posts-feed-tabs'>
|
||||
<Button
|
||||
variant={mode === FeedTabsPage.RECENT_MODE ? 'dark' : 'outline-dark'}
|
||||
onClick={() => handleSelectTab('Recent')}
|
||||
>
|
||||
Recent
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant={mode === FeedTabsPage.FOLLOWING_MODE ? 'dark' : 'outline-dark'}
|
||||
onClick={() => handleSelectTab('Following')}
|
||||
>
|
||||
Following
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{pagination && posts.length > 0 && (
|
||||
<span className='recent-posts-count'>
|
||||
Showing {pagination.offset + 1}–
|
||||
@@ -103,7 +159,7 @@ function RecentPosts (props) {
|
||||
</span>
|
||||
)}
|
||||
|
||||
{pagination && posts.length === 0 && (
|
||||
{pagination && posts.length === 0 && !emptyBecauseNoFollows && (
|
||||
<span className='recent-posts-count'>
|
||||
No posts on this page.
|
||||
</span>
|
||||
@@ -126,6 +182,12 @@ function RecentPosts (props) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && posts.length === 0 && emptyBecauseNoFollows && (
|
||||
<p className='recent-posts-empty'>
|
||||
You are not following anyone.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && posts.length > 0 && (
|
||||
<div className='posts-feed'>
|
||||
{posts.map((post) => (
|
||||
@@ -133,7 +195,7 @@ function RecentPosts (props) {
|
||||
key={post.txid}
|
||||
post={post}
|
||||
profiles={profiles}
|
||||
wallet={appData?.wallet}
|
||||
wallet={wallet}
|
||||
onReplyClick={() => openThread(post.txid)}
|
||||
showFooterMeta
|
||||
/>
|
||||
@@ -167,7 +229,7 @@ function RecentPosts (props) {
|
||||
show={showThreadModal}
|
||||
txid={threadTxid}
|
||||
onHide={closeThread}
|
||||
wallet={appData?.wallet}
|
||||
wallet={wallet}
|
||||
profiles={profiles}
|
||||
/>
|
||||
</Container>
|
||||
|
||||
@@ -69,14 +69,6 @@ function NavMenu (props) {
|
||||
New Post
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
className={currentPath === '/posts/following' ? 'nav-link-active' : 'nav-link-inactive'}
|
||||
to='/posts/following'
|
||||
onClick={handleClickEvent}
|
||||
>
|
||||
Following
|
||||
</NavLink>
|
||||
|
||||
<NavLink
|
||||
className={currentPath === '/profile/recent' ? 'nav-link-active' : 'nav-link-inactive'}
|
||||
to='/profile/recent'
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
Merged posts feed page behavior: one paginated posts page with a row of two
|
||||
mode buttons, "Recent" and "Following".
|
||||
|
||||
On first open the page asks the MemoDb client which accounts the viewer
|
||||
follows. If the viewer follows at least one account it selects the Following
|
||||
tab, otherwise the Recent tab. Changing tabs resets the feed to its first
|
||||
page. Loading is delegated to the existing recent and following page
|
||||
controllers so this module stays a thin, testable coordinator free of UI or
|
||||
network concerns.
|
||||
*/
|
||||
|
||||
const RecentFeedPage = require('./recent-feed-page')
|
||||
const FollowingFeedPage = require('./following-feed-page')
|
||||
|
||||
const RECENT_MODE = 'recent'
|
||||
const FOLLOWING_MODE = 'following'
|
||||
const TABS = ['Recent', 'Following']
|
||||
|
||||
class FeedTabsPage {
|
||||
constructor (deps = {}) {
|
||||
this.memoDb = deps.memoDb || null
|
||||
this.wallet = deps.wallet || null
|
||||
this.recentPage = deps.recentPage || new RecentFeedPage({ memoDb: this.memoDb, wallet: this.wallet })
|
||||
this.followingPage = deps.followingPage || new FollowingFeedPage({ memoDb: this.memoDb, wallet: this.wallet })
|
||||
this.tabs = [...TABS]
|
||||
this.mode = null
|
||||
this.pageSize = 50
|
||||
this.offset = 0
|
||||
this.posts = []
|
||||
this.pagination = null
|
||||
this.hasFollows = false
|
||||
this.emptyBecauseNoFollows = false
|
||||
}
|
||||
|
||||
getMyAddress () {
|
||||
return this.wallet?.walletInfo?.cashAddress || null
|
||||
}
|
||||
|
||||
// Open the merged page: choose the default tab from the viewer's follow
|
||||
// state, then load its first (or requested) page.
|
||||
async open ({ limit = 50, offset = 0 } = {}) {
|
||||
if (!this.memoDb) {
|
||||
throw new Error('Feed tabs page requires a memo db client.')
|
||||
}
|
||||
|
||||
this.pageSize = limit
|
||||
this.offset = offset
|
||||
|
||||
const myAddr = this.getMyAddress()
|
||||
const followees = myAddr ? await this.memoDb.getFollowing(myAddr) : []
|
||||
this.hasFollows = Array.isArray(followees) && followees.length > 0
|
||||
this.mode = this.hasFollows ? FOLLOWING_MODE : RECENT_MODE
|
||||
|
||||
await this._loadMode()
|
||||
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
// Switch to a tab by its label ("Recent"/"Following") or internal mode.
|
||||
// Changing tabs resets the feed to its first page. Selecting the tab that is
|
||||
// already active is a no-op.
|
||||
async selectTab (tab) {
|
||||
const mode = this._normalizeTab(tab)
|
||||
if (mode === this.mode) {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
this.mode = mode
|
||||
this.offset = 0
|
||||
await this._loadMode()
|
||||
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
// Load the current tab at the current offset through its page controller.
|
||||
async _loadMode () {
|
||||
if (this.mode === FOLLOWING_MODE) {
|
||||
const data = await this.followingPage.load({ limit: this.pageSize, offset: this.offset })
|
||||
this.posts = data.posts || []
|
||||
this.pagination = data.pagination || null
|
||||
this.emptyBecauseNoFollows = this.posts.length === 0 && !this.hasFollows
|
||||
return
|
||||
}
|
||||
|
||||
const data = await this.recentPage.load({ limit: this.pageSize, offset: this.offset })
|
||||
this.posts = data.posts || []
|
||||
this.pagination = data.pagination || null
|
||||
this.emptyBecauseNoFollows = false
|
||||
}
|
||||
|
||||
_normalizeTab (tab) {
|
||||
const value = String(tab).toLowerCase()
|
||||
if (value === 'recent') return RECENT_MODE
|
||||
if (value === 'following') return FOLLOWING_MODE
|
||||
throw new Error(`Unknown feed tab: ${tab}`)
|
||||
}
|
||||
|
||||
isRecent () {
|
||||
return this.mode === RECENT_MODE
|
||||
}
|
||||
|
||||
isFollowing () {
|
||||
return this.mode === FOLLOWING_MODE
|
||||
}
|
||||
|
||||
getActiveTabLabel () {
|
||||
return this.mode === FOLLOWING_MODE ? 'Following' : 'Recent'
|
||||
}
|
||||
|
||||
canLoadMore () {
|
||||
return this.pagination?.hasMore ?? false
|
||||
}
|
||||
|
||||
async nextPage () {
|
||||
if (!this.canLoadMore()) {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
this.offset += this.pageSize
|
||||
await this._loadMode()
|
||||
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
async previousPage () {
|
||||
const target = Math.max(0, this.offset - this.pageSize)
|
||||
if (target === this.offset) {
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
this.offset = target
|
||||
await this._loadMode()
|
||||
|
||||
return this.getState()
|
||||
}
|
||||
|
||||
getPost (txid) {
|
||||
return this.posts.find((post) => post.txid === txid) || null
|
||||
}
|
||||
|
||||
getState () {
|
||||
return {
|
||||
mode: this.mode,
|
||||
offset: this.offset,
|
||||
posts: this.posts,
|
||||
pagination: this.pagination,
|
||||
emptyBecauseNoFollows: this.emptyBecauseNoFollows
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FeedTabsPage.TABS = TABS
|
||||
FeedTabsPage.RECENT_MODE = RECENT_MODE
|
||||
FeedTabsPage.FOLLOWING_MODE = FOLLOWING_MODE
|
||||
|
||||
module.exports = FeedTabsPage
|
||||
@@ -160,6 +160,10 @@ class MemoDb {
|
||||
}
|
||||
}
|
||||
|
||||
async getFollowing (addr) {
|
||||
return this._getList(`/follow/following/${encodeURIComponent(addr)}`, 'getFollowing', 'following')
|
||||
}
|
||||
|
||||
async getFollowingFeed (addr, opts = {}) {
|
||||
return this.getPage(`/posts/following/${encodeURIComponent(addr)}`, 'getFollowingFeed', opts)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/*
|
||||
Unit tests for the merged posts feed page controller.
|
||||
|
||||
FeedTabsPage merges the recent feed and the following feed behind a row of
|
||||
two mode buttons ("Recent" / "Following"). It selects the default mode from
|
||||
the viewer's follow state, delegates loading to the recent/following page
|
||||
controllers, and resets to the first page whenever the tab changes.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const FeedTabsPage = require('../../src/services/feed-tabs-page')
|
||||
|
||||
const MY = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
const ALICE = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
|
||||
|
||||
function makeWallet (address = MY) {
|
||||
return { walletInfo: { cashAddress: address } }
|
||||
}
|
||||
|
||||
function makeMemoDb ({
|
||||
following = [],
|
||||
recent = { posts: [], pagination: { total: 0, limit: 50, offset: 0, hasMore: false } },
|
||||
followingFeed = { posts: [], pagination: { total: 0, limit: 50, offset: 0, hasMore: false } }
|
||||
} = {}) {
|
||||
const calls = { getFollowing: [], getRecentPosts: [], getFollowingFeed: [] }
|
||||
return {
|
||||
calls,
|
||||
async getFollowing (addr) {
|
||||
calls.getFollowing.push(addr)
|
||||
return following
|
||||
},
|
||||
async getRecentPosts (opts) {
|
||||
calls.getRecentPosts.push(opts)
|
||||
return recent
|
||||
},
|
||||
async getFollowingFeed (addr, opts) {
|
||||
calls.getFollowingFeed.push({ addr, opts })
|
||||
return followingFeed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('exposes the Recent and Following tabs in order', () => {
|
||||
const page = new FeedTabsPage({ memoDb: makeMemoDb(), wallet: makeWallet() })
|
||||
assert.deepEqual(page.tabs, ['Recent', 'Following'])
|
||||
})
|
||||
|
||||
test('open selects Following and loads followed posts when the viewer follows an account', async () => {
|
||||
const followed = [{ txid: 'a'.repeat(64), text: 'followed' }]
|
||||
const memoDb = makeMemoDb({
|
||||
following: [ALICE],
|
||||
followingFeed: { posts: followed, pagination: { total: 1, limit: 50, offset: 0, hasMore: false } }
|
||||
})
|
||||
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })
|
||||
|
||||
await page.open()
|
||||
|
||||
assert.equal(page.isFollowing(), true)
|
||||
assert.equal(page.isRecent(), false)
|
||||
assert.deepEqual(page.posts, followed)
|
||||
assert.equal(memoDb.calls.getFollowing.length, 1)
|
||||
assert.equal(memoDb.calls.getRecentPosts.length, 0)
|
||||
assert.equal(memoDb.calls.getFollowingFeed.length, 1)
|
||||
})
|
||||
|
||||
test('open selects Recent and loads recent posts when the viewer follows no one', async () => {
|
||||
const recent = [{ txid: 'b'.repeat(64), text: 'recent' }]
|
||||
const memoDb = makeMemoDb({
|
||||
following: [],
|
||||
recent: { posts: recent, pagination: { total: 1, limit: 50, offset: 0, hasMore: false } }
|
||||
})
|
||||
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })
|
||||
|
||||
await page.open()
|
||||
|
||||
assert.equal(page.isRecent(), true)
|
||||
assert.equal(page.isFollowing(), false)
|
||||
assert.deepEqual(page.posts, recent)
|
||||
assert.equal(memoDb.calls.getRecentPosts.length, 1)
|
||||
assert.equal(memoDb.calls.getFollowingFeed.length, 0)
|
||||
})
|
||||
|
||||
test('open asks the memo db which accounts the viewer follows', async () => {
|
||||
const memoDb = makeMemoDb({ following: [ALICE] })
|
||||
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })
|
||||
|
||||
await page.open()
|
||||
|
||||
assert.deepEqual(memoDb.calls.getFollowing, [MY])
|
||||
})
|
||||
|
||||
test('open forwards the page size and offset to the selected feed', async () => {
|
||||
const memoDb = makeMemoDb({ following: [] })
|
||||
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })
|
||||
|
||||
await page.open({ limit: 2, offset: 4 })
|
||||
|
||||
assert.deepEqual(memoDb.calls.getRecentPosts, [{ limit: 2, offset: 4, viewer: MY }])
|
||||
})
|
||||
|
||||
test('open forwards the page size to the following feed when the viewer follows an account', async () => {
|
||||
const memoDb = makeMemoDb({ following: [ALICE] })
|
||||
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })
|
||||
|
||||
await page.open({ limit: 2, offset: 0 })
|
||||
|
||||
assert.deepEqual(memoDb.calls.getFollowingFeed, [{ addr: MY, opts: { limit: 2, offset: 0 } }])
|
||||
})
|
||||
|
||||
test('open selects Recent and skips the follow lookup when no wallet is available', async () => {
|
||||
const memoDb = makeMemoDb({ following: [ALICE] })
|
||||
const page = new FeedTabsPage({ memoDb, wallet: null })
|
||||
|
||||
await page.open()
|
||||
|
||||
assert.equal(page.isRecent(), true)
|
||||
assert.deepEqual(memoDb.calls.getFollowing, [])
|
||||
})
|
||||
|
||||
test('open throws when no memo db client is provided', async () => {
|
||||
const page = new FeedTabsPage({})
|
||||
|
||||
await assert.rejects(() => page.open(), /requires a memo db client/)
|
||||
})
|
||||
|
||||
test('selectTab Recent switches from Following and resets to the first page', async () => {
|
||||
const memoDb = makeMemoDb({ following: [ALICE] })
|
||||
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })
|
||||
|
||||
await page.open({ limit: 2 })
|
||||
page.offset = 6
|
||||
await page.selectTab('Recent')
|
||||
|
||||
assert.equal(page.isRecent(), true)
|
||||
assert.equal(page.offset, 0)
|
||||
assert.deepEqual(memoDb.calls.getRecentPosts, [{ limit: 2, offset: 0, viewer: MY }])
|
||||
})
|
||||
|
||||
test('selectTab Following switches from Recent and resets to the first page', async () => {
|
||||
const memoDb = makeMemoDb({ following: [ALICE] })
|
||||
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })
|
||||
|
||||
await page.open({ limit: 2 })
|
||||
await page.selectTab('Recent')
|
||||
page.offset = 4
|
||||
await page.selectTab('Following')
|
||||
|
||||
assert.equal(page.isFollowing(), true)
|
||||
assert.equal(page.offset, 0)
|
||||
assert.deepEqual(memoDb.calls.getFollowingFeed[1], { addr: MY, opts: { limit: 2, offset: 0 } })
|
||||
})
|
||||
|
||||
test('selecting the already active tab does not reload', async () => {
|
||||
const memoDb = makeMemoDb({ following: [] })
|
||||
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })
|
||||
|
||||
await page.open()
|
||||
await page.selectTab('Recent')
|
||||
|
||||
assert.equal(memoDb.calls.getRecentPosts.length, 1)
|
||||
})
|
||||
|
||||
test('Following tab with no followees shows the not-following-anyone message', async () => {
|
||||
const memoDb = makeMemoDb({ following: [] })
|
||||
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })
|
||||
|
||||
await page.open()
|
||||
await page.selectTab('Following')
|
||||
|
||||
assert.equal(page.posts.length, 0)
|
||||
assert.equal(page.emptyBecauseNoFollows, true)
|
||||
})
|
||||
|
||||
test('Following tab with followees but no posts does not show the not-following-anyone message', async () => {
|
||||
const memoDb = makeMemoDb({
|
||||
following: [ALICE],
|
||||
followingFeed: { posts: [], pagination: { total: 0, limit: 50, offset: 0, hasMore: false } }
|
||||
})
|
||||
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })
|
||||
|
||||
await page.open()
|
||||
|
||||
assert.equal(page.isFollowing(), true)
|
||||
assert.equal(page.posts.length, 0)
|
||||
assert.equal(page.emptyBecauseNoFollows, false)
|
||||
})
|
||||
|
||||
test('Recent tab never shows the not-following-anyone message', async () => {
|
||||
const memoDb = makeMemoDb({ following: [] })
|
||||
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })
|
||||
|
||||
await page.open()
|
||||
|
||||
assert.equal(page.emptyBecauseNoFollows, false)
|
||||
})
|
||||
|
||||
test('canLoadMore reflects the pagination hasMore flag', async () => {
|
||||
const memoDb = makeMemoDb({
|
||||
following: [],
|
||||
recent: { posts: [{ txid: 'c'.repeat(64), text: 'one' }], pagination: { total: 3, limit: 2, offset: 0, hasMore: true } }
|
||||
})
|
||||
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })
|
||||
|
||||
await page.open({ limit: 2 })
|
||||
|
||||
assert.equal(page.canLoadMore(), true)
|
||||
})
|
||||
|
||||
test('nextPage advances the offset by the page size and loads the next page', async () => {
|
||||
const memoDb = makeMemoDb({ following: [] })
|
||||
memoDb.getRecentPosts = async (opts) => {
|
||||
memoDb.calls.getRecentPosts.push(opts)
|
||||
return { posts: opts.offset === 0 ? [{ txid: 'c'.repeat(64), text: 'one' }] : [], pagination: { total: 1, limit: opts.limit, offset: opts.offset, hasMore: opts.offset === 0 } }
|
||||
}
|
||||
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })
|
||||
|
||||
await page.open({ limit: 2 })
|
||||
await page.nextPage()
|
||||
|
||||
assert.equal(page.offset, 2)
|
||||
assert.deepEqual(memoDb.calls.getRecentPosts[1], { limit: 2, offset: 2, viewer: MY })
|
||||
})
|
||||
|
||||
test('nextPage does nothing when there are no more posts', async () => {
|
||||
const memoDb = makeMemoDb({ following: [] })
|
||||
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })
|
||||
|
||||
await page.open({ limit: 2 })
|
||||
await page.nextPage()
|
||||
|
||||
assert.equal(page.offset, 0)
|
||||
assert.equal(memoDb.calls.getRecentPosts.length, 1)
|
||||
})
|
||||
|
||||
test('previousPage moves back a page and never before the first page', async () => {
|
||||
const memoDb = makeMemoDb({ following: [] })
|
||||
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })
|
||||
|
||||
await page.open({ limit: 2 })
|
||||
page.offset = 4
|
||||
await page.previousPage()
|
||||
await page.previousPage()
|
||||
await page.previousPage()
|
||||
|
||||
assert.equal(page.offset, 0)
|
||||
})
|
||||
|
||||
test('getPost returns a loaded post by txid', async () => {
|
||||
const post = { txid: 'd'.repeat(64), text: 'hello' }
|
||||
const memoDb = makeMemoDb({
|
||||
following: [],
|
||||
recent: { posts: [post], pagination: { total: 1, limit: 50, offset: 0, hasMore: false } }
|
||||
})
|
||||
const page = new FeedTabsPage({ memoDb, wallet: makeWallet() })
|
||||
|
||||
await page.open()
|
||||
|
||||
assert.equal(page.getPost(post.txid), post)
|
||||
assert.equal(page.getPost('e'.repeat(64)), null)
|
||||
})
|
||||
Reference in New Issue
Block a user