diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js
index 6e60f3b..7bab3af 100644
--- a/psf-memo-client/acceptance/lib/handlers.js
+++ b/psf-memo-client/acceptance/lib/handlers.js
@@ -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 (.+)$/,
diff --git a/psf-memo-client/specs/feed-tabs.feature b/psf-memo-client/specs/feed-tabs.feature
index 170694f..47427f9 100644
--- a/psf-memo-client/specs/feed-tabs.feature
+++ b/psf-memo-client/specs/feed-tabs.feature
@@ -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 ""
- And the posts feed does not show the post with text ""
Examples:
| followee | followed_txid | followed_text | other | other_txid | other_text |
diff --git a/psf-memo-client/src/components/app-body/following-feed/index.js b/psf-memo-client/src/components/app-body/following-feed/index.js
deleted file mode 100644
index 83d2b6b..0000000
--- a/psf-memo-client/src/components/app-body/following-feed/index.js
+++ /dev/null
@@ -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 (
-
-
-
-
-
- {error && (
-
- {error}
-
- )}
-
- {loading && (
-
-
-
- Loading...
-
-
-
- )}
-
- {!loading && !error && posts.length === 0 && (
-
- {emptyBecauseNoFollows
- ? 'You are not following anyone.'
- : 'No posts from profiles you follow.'}
-
- )}
-
- {!loading && !error && posts.length > 0 && (
-
- {posts.map((post) => (
-
openThread(post.txid)}
- showFooterMeta
- />
- ))}
-
- )}
-
- {!loading && !error && (pagination || offset > 0) && (
-
-
-
-
-
- )}
-
-
-
-
-
- )
-}
-
-export default FollowingFeed
diff --git a/psf-memo-client/src/components/app-body/index.js b/psf-memo-client/src/components/app-body/index.js
index a482d0f..b23aad3 100644
--- a/psf-memo-client/src/components/app-body/index.js
+++ b/psf-memo-client/src/components/app-body/index.js
@@ -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) {
} />
} />
} />
- } />
} />
} />
} />
diff --git a/psf-memo-client/src/components/app-body/posts/index.js b/psf-memo-client/src/components/app-body/posts/index.js
index 592fe1a..90d442d 100644
--- a/psf-memo-client/src/components/app-body/posts/index.js
+++ b/psf-memo-client/src/components/app-body/posts/index.js
@@ -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 (
@@ -96,6 +136,22 @@ function RecentPosts (props) {
Recent messages published through the Memo protocol on Bitcoin Cash.
+
+
+
+
+
+
{pagination && posts.length > 0 && (
Showing {pagination.offset + 1}–
@@ -103,7 +159,7 @@ function RecentPosts (props) {
)}
- {pagination && posts.length === 0 && (
+ {pagination && posts.length === 0 && !emptyBecauseNoFollows && (
No posts on this page.
@@ -126,6 +182,12 @@ function RecentPosts (props) {
)}
+ {!loading && !error && posts.length === 0 && emptyBecauseNoFollows && (
+
+ You are not following anyone.
+
+ )}
+
{!loading && !error && posts.length > 0 && (
{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}
/>
diff --git a/psf-memo-client/src/components/nav-menu/index.js b/psf-memo-client/src/components/nav-menu/index.js
index 58f0c0a..81fb043 100644
--- a/psf-memo-client/src/components/nav-menu/index.js
+++ b/psf-memo-client/src/components/nav-menu/index.js
@@ -69,14 +69,6 @@ function NavMenu (props) {
New Post
-
- Following
-
-
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
diff --git a/psf-memo-client/src/services/memo-db.js b/psf-memo-client/src/services/memo-db.js
index 5541281..45d8f00 100644
--- a/psf-memo-client/src/services/memo-db.js
+++ b/psf-memo-client/src/services/memo-db.js
@@ -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)
}
diff --git a/psf-memo-client/test/unit/feed-tabs-page.test.js b/psf-memo-client/test/unit/feed-tabs-page.test.js
new file mode 100644
index 0000000..0c60906
--- /dev/null
+++ b/psf-memo-client/test/unit/feed-tabs-page.test.js
@@ -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)
+})