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 ( - - - -
-

Following

-

Posts from profiles you follow.

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

- {error} -

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

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

- )} - - {!loading && !error && posts.length > 0 && ( -
- {posts.map((post) => ( - openThread(post.txid)} - showFooterMeta - /> - ))} -
- )} - - {!loading && !error && (pagination || offset > 0) && ( -
- - - -
- )} - -
- - -
- ) -} - -export default FollowingFeed diff --git a/psf-memo-client/src/components/app-body/index.js b/psf-memo-client/src/components/app-body/index.js index 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) +})