mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Implement topic recency ordering and pagination
- Indexer maintains topicSummaries and topicRecency for topic messages and follows, idempotently, moving each room's recency record to its newest post. - DB adds the two index stores and serves GET /topics from them with limit/offset pagination, without iterating the rooms store. Adds util/room/backfill-topic-indexes.js to build the indexes idempotently. - Client topics page loads 50 per page with Previous/Next. By coder.
This commit is contained in:
@@ -361,13 +361,18 @@ function makeMemoDb () {
|
||||
async getMuteState (muterAddr, muteeAddr) {
|
||||
return muteState[`${muterAddr}:${muteeAddr}`] || false
|
||||
},
|
||||
async getTopics () {
|
||||
async getTopics ({ limit = 50, offset = 0 } = {}) {
|
||||
const list = []
|
||||
for (const [room, postCount] of topicCounts.entries()) {
|
||||
list.push({ room, postCount })
|
||||
}
|
||||
list.sort((a, b) => a.room.localeCompare(b.room))
|
||||
return { topics: list }
|
||||
const total = list.length
|
||||
const page = list.slice(offset, offset + limit)
|
||||
return {
|
||||
topics: page,
|
||||
pagination: { limit, offset, total, hasMore: offset + page.length < total }
|
||||
}
|
||||
},
|
||||
async getTopicPosts (room, { limit = 50, offset = 0, viewer = null } = {}) {
|
||||
let all = topicPosts[room] || []
|
||||
@@ -2141,6 +2146,54 @@ const handlers = [
|
||||
world.currentPath = TopicDiscoveryPage.TOPICS_PATH
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'open topics page at offset',
|
||||
pattern: /^I open the topics page at offset (<[A-Za-z0-9_]+>)$/,
|
||||
async run (m, example, world) {
|
||||
const offset = parseInt(resolveParam(m[1], example), 10)
|
||||
await world.topicDiscoveryPage.load({ limit: 50, offset })
|
||||
world.currentPath = TopicDiscoveryPage.TOPICS_PATH
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'API serves N topics',
|
||||
pattern: /^the psf-memo-db API serves (<[A-Za-z0-9_]+>) topics$/,
|
||||
run (m, example, world) {
|
||||
const count = parseInt(resolveParam(m[1], example), 10)
|
||||
for (let i = 0; i < count; i++) {
|
||||
world.memoDb.addTopic(`topic-${String(i + 1).padStart(3, '0')}`, 1)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topics page shows N topics',
|
||||
pattern: /^the topics page shows (<[A-Za-z0-9_]+>) topics$/,
|
||||
run (m, example, world) {
|
||||
const expected = parseInt(resolveParam(m[1], example), 10)
|
||||
const actual = world.topicDiscoveryPage.topics.length
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Expected ${expected} topics on the topics page, got ${actual}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topics page can load more topics',
|
||||
pattern: /^the topics page can load more topics$/,
|
||||
run (m, example, world) {
|
||||
if (!world.topicDiscoveryPage.canLoadMore()) {
|
||||
throw new Error('Expected the topics page to have more topics, but pagination says there are none.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topics page has no more topics',
|
||||
pattern: /^the topics page has no more topics$/,
|
||||
run (m, example, world) {
|
||||
if (world.topicDiscoveryPage.canLoadMore()) {
|
||||
throw new Error('Expected the topics page to have no more topics, but pagination says there are more.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topics page shows topic count',
|
||||
pattern: /^the topics page shows the topic (<topic>) with (<count>) posts$/,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
/*
|
||||
Display the list of Memo topics served by psf-memo-db.
|
||||
Display a page of Memo topics served by psf-memo-db.
|
||||
*/
|
||||
|
||||
// Global npm libraries
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Container, Row, Col, Spinner, ListGroup } from 'react-bootstrap'
|
||||
import { Container, Row, Col, Spinner, ListGroup, Button } from 'react-bootstrap'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
|
||||
// Local libraries
|
||||
@@ -12,11 +12,15 @@ import MemoDb from '../../../services/memo-db'
|
||||
import TopicDiscoveryPage from '../../../services/topic-discovery-page'
|
||||
import '../../../App.css'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
|
||||
function Topics (props) {
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [topics, setTopics] = useState([])
|
||||
const [pagination, setPagination] = useState(null)
|
||||
const [offset, setOffset] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
const loadTopics = async () => {
|
||||
@@ -26,23 +30,36 @@ function Topics (props) {
|
||||
try {
|
||||
const memoDb = new MemoDb()
|
||||
const page = new TopicDiscoveryPage({ memoDb, navigate })
|
||||
const result = await page.load()
|
||||
const result = await page.load({ limit: PAGE_SIZE, offset })
|
||||
setTopics(result.topics || [])
|
||||
setPagination(result.pagination || null)
|
||||
} catch (err) {
|
||||
setError(err.message || 'Failed to load topics')
|
||||
setTopics([])
|
||||
setPagination(null)
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
loadTopics()
|
||||
}, [navigate])
|
||||
}, [navigate, offset])
|
||||
|
||||
const handleClick = (room) => {
|
||||
navigate(TopicDiscoveryPage.topicFeedPath(room))
|
||||
}
|
||||
|
||||
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='topics-page'>
|
||||
<Row className='justify-content-center'>
|
||||
@@ -50,6 +67,13 @@ function Topics (props) {
|
||||
<header className='topics-heading'>
|
||||
<h1>Topics</h1>
|
||||
<p>Discover Memo conversations organized by topic.</p>
|
||||
|
||||
{pagination && topics.length > 0 && (
|
||||
<span className='topics-count'>
|
||||
Showing {pagination.offset + 1}–
|
||||
{pagination.offset + topics.length} of {pagination.total}
|
||||
</span>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
@@ -84,6 +108,26 @@ function Topics (props) {
|
||||
))}
|
||||
</ListGroup>
|
||||
)}
|
||||
|
||||
{!loading && !error && (pagination || offset > 0) && (
|
||||
<div className='topics-pagination mt-3'>
|
||||
<Button
|
||||
variant='outline-dark'
|
||||
onClick={handlePrevious}
|
||||
disabled={!canGoBack}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant='outline-dark'
|
||||
onClick={handleNext}
|
||||
disabled={!canGoNext}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
</Container>
|
||||
|
||||
@@ -44,8 +44,8 @@ class MemoDb {
|
||||
return this._getList(`/mute/muted/${encodeURIComponent(muterAddr)}`, 'getMuted', 'muted')
|
||||
}
|
||||
|
||||
async getTopics () {
|
||||
return this.getRecent('/topics', 'getTopics', {})
|
||||
async getTopics ({ limit = 50, offset = 0 } = {}) {
|
||||
return this.getRecent('/topics', 'getTopics', { limit, offset })
|
||||
}
|
||||
|
||||
async getTopicPosts (room, { limit = 50, offset = 0, viewer = null } = {}) {
|
||||
|
||||
@@ -1,29 +1,23 @@
|
||||
/*
|
||||
Topic Discovery Page behavior: load and display the list of Memo topics.
|
||||
Topic Discovery Page behavior: load and display a page of Memo topics.
|
||||
|
||||
This is the testable controller behind the React "Topics" page. It wraps
|
||||
the MemoDb client and exposes the loaded topics so the view can render each
|
||||
topic name and post count.
|
||||
the MemoDb client and exposes the loaded topics and pagination so the view
|
||||
can render each topic name and post count and move between pages.
|
||||
*/
|
||||
|
||||
const PaginatedPage = require('./paginated-page')
|
||||
|
||||
const TOPICS_PATH = '/topics'
|
||||
|
||||
class TopicDiscoveryPage {
|
||||
class TopicDiscoveryPage extends PaginatedPage {
|
||||
constructor (deps = {}) {
|
||||
this.memoDb = deps.memoDb || null
|
||||
super(deps, {
|
||||
listField: 'topics',
|
||||
loadMethod: 'getTopics',
|
||||
errorMessage: 'Topic discovery page requires a memo db client.'
|
||||
})
|
||||
this.navigate = deps.navigate || (() => {})
|
||||
this.topics = []
|
||||
}
|
||||
|
||||
async load () {
|
||||
if (!this.memoDb) {
|
||||
throw new Error('Topic discovery page requires a memo db client.')
|
||||
}
|
||||
|
||||
const data = await this.memoDb.getTopics()
|
||||
this.topics = data.topics || []
|
||||
|
||||
return { topics: this.topics }
|
||||
}
|
||||
|
||||
getTopic (room) {
|
||||
|
||||
@@ -8,10 +8,13 @@ const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const TopicDiscoveryPage = require('../../src/services/topic-discovery-page')
|
||||
|
||||
function makeMemoDb (topics) {
|
||||
function makeMemoDb ({ topics = [], pagination = null } = {}) {
|
||||
const calls = []
|
||||
return {
|
||||
async getTopics () {
|
||||
return { topics }
|
||||
calls,
|
||||
async getTopics (opts) {
|
||||
calls.push(opts)
|
||||
return { topics, pagination }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,13 +24,56 @@ test('load returns topics with post counts', async () => {
|
||||
{ room: 'bitcoin', postCount: 2 },
|
||||
{ room: 'cash', postCount: 1 }
|
||||
]
|
||||
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb(topics) })
|
||||
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb({ topics }) })
|
||||
|
||||
const result = await page.load()
|
||||
|
||||
assert.deepEqual(result.topics, topics)
|
||||
})
|
||||
|
||||
test('load defaults to a page of 50 from offset 0', async () => {
|
||||
const memoDb = makeMemoDb({ topics: [] })
|
||||
const page = new TopicDiscoveryPage({ memoDb })
|
||||
|
||||
await page.load()
|
||||
|
||||
assert.deepEqual(memoDb.calls[0], { limit: 50, offset: 0 })
|
||||
})
|
||||
|
||||
test('load forwards limit and offset and stores pagination', async () => {
|
||||
const memoDb = makeMemoDb({
|
||||
topics: [{ room: 'bitcoin', postCount: 2 }],
|
||||
pagination: { limit: 50, offset: 50, total: 60, hasMore: false }
|
||||
})
|
||||
const page = new TopicDiscoveryPage({ memoDb })
|
||||
|
||||
const result = await page.load({ limit: 50, offset: 50 })
|
||||
|
||||
assert.deepEqual(memoDb.calls[0], { limit: 50, offset: 50 })
|
||||
assert.deepEqual(result.pagination, { limit: 50, offset: 50, total: 60, hasMore: false })
|
||||
assert.equal(page.pagination.offset, 50)
|
||||
})
|
||||
|
||||
test('canLoadMore reflects the pagination hasMore flag', async () => {
|
||||
const more = new TopicDiscoveryPage({
|
||||
memoDb: makeMemoDb({
|
||||
topics: [{ room: 'a', postCount: 0 }],
|
||||
pagination: { limit: 50, offset: 0, total: 60, hasMore: true }
|
||||
})
|
||||
})
|
||||
await more.load()
|
||||
assert.equal(more.canLoadMore(), true)
|
||||
|
||||
const last = new TopicDiscoveryPage({
|
||||
memoDb: makeMemoDb({
|
||||
topics: [{ room: 'a', postCount: 0 }],
|
||||
pagination: { limit: 50, offset: 50, total: 60, hasMore: false }
|
||||
})
|
||||
})
|
||||
await last.load()
|
||||
assert.equal(last.canLoadMore(), false)
|
||||
})
|
||||
|
||||
test('load throws when no memo db client is provided', async () => {
|
||||
const page = new TopicDiscoveryPage({})
|
||||
|
||||
@@ -39,7 +85,7 @@ test('load throws when no memo db client is provided', async () => {
|
||||
|
||||
test('stores the provided navigate function', () => {
|
||||
const navigate = () => {}
|
||||
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb([]), navigate })
|
||||
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb({ topics: [] }), navigate })
|
||||
|
||||
assert.equal(page.navigate, navigate)
|
||||
})
|
||||
@@ -49,7 +95,7 @@ test('getTopic returns the matching topic', async () => {
|
||||
{ room: 'bitcoin', postCount: 2 },
|
||||
{ room: 'cash', postCount: 1 }
|
||||
]
|
||||
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb(topics) })
|
||||
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb({ topics }) })
|
||||
|
||||
await page.load()
|
||||
|
||||
@@ -57,7 +103,7 @@ test('getTopic returns the matching topic', async () => {
|
||||
})
|
||||
|
||||
test('getTopic returns null when the topic is not loaded', async () => {
|
||||
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb([]) })
|
||||
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb({ topics: [] }) })
|
||||
|
||||
await page.load()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user