mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Implement topic metadata columns
Add lastSeen and followerCount to topicSummaries across the indexer, DB, and client. The indexer now tracks the newest post seen time, updates follower counts idempotently on follow/unfollow, and preserves post metadata. The DB exposes lastSeen and followerCount from GET /topics and rebuilds both from the rooms store in the backfill. The client renders the four-column topics page with relative-time labels. By coder.
This commit is contained in:
@@ -217,6 +217,7 @@ function makeMemoDb () {
|
||||
const topicPosts = {}
|
||||
const topicCounts = new Map()
|
||||
const topicFollow = new Map()
|
||||
const topicMetadata = new Map()
|
||||
|
||||
return {
|
||||
posts,
|
||||
@@ -228,6 +229,7 @@ function makeMemoDb () {
|
||||
topics,
|
||||
topicPosts,
|
||||
topicCounts,
|
||||
topicMetadata,
|
||||
addPost (post) {
|
||||
posts.push(post)
|
||||
},
|
||||
@@ -274,6 +276,7 @@ function makeMemoDb () {
|
||||
},
|
||||
addTopic (room, postCount) {
|
||||
topicCounts.set(room, postCount)
|
||||
topicMetadata.set(room, { followerCount: 0, lastSeen: 0 })
|
||||
topicPosts[room] = []
|
||||
for (let i = 0; i < postCount; i++) {
|
||||
const txid = `${room}-post-${i + 1}`.padEnd(64, '0')
|
||||
@@ -285,6 +288,16 @@ function makeMemoDb () {
|
||||
})
|
||||
}
|
||||
},
|
||||
setTopicFollowerCount (room, followerCount) {
|
||||
const meta = topicMetadata.get(room) || {}
|
||||
meta.followerCount = followerCount
|
||||
topicMetadata.set(room, meta)
|
||||
},
|
||||
setTopicLastSeen (room, lastSeen) {
|
||||
const meta = topicMetadata.get(room) || {}
|
||||
meta.lastSeen = lastSeen
|
||||
topicMetadata.set(room, meta)
|
||||
},
|
||||
addTopicPost (room, post) {
|
||||
if (!topicPosts[room]) topicPosts[room] = []
|
||||
topicPosts[room].push(post)
|
||||
@@ -364,7 +377,13 @@ function makeMemoDb () {
|
||||
async getTopics ({ limit = 50, offset = 0 } = {}) {
|
||||
const list = []
|
||||
for (const [room, postCount] of topicCounts.entries()) {
|
||||
list.push({ room, postCount })
|
||||
const meta = topicMetadata.get(room) || {}
|
||||
list.push({
|
||||
room,
|
||||
postCount,
|
||||
lastSeen: meta.lastSeen ?? 0,
|
||||
followerCount: meta.followerCount ?? 0
|
||||
})
|
||||
}
|
||||
list.sort((a, b) => a.room.localeCompare(b.room))
|
||||
const total = list.length
|
||||
@@ -2101,13 +2120,38 @@ const handlers = [
|
||||
},
|
||||
{
|
||||
name: 'API serves topic with post count',
|
||||
pattern: /^the psf-memo-db API serves a topic named "([^"]+)" with (\d+) posts?$/,
|
||||
pattern: /^the psf-memo-db API serves a topic named "([^"]+)" with (\S+) posts?$/,
|
||||
run (m, example, world) {
|
||||
const room = m[1]
|
||||
const count = parseInt(m[2], 10)
|
||||
const room = resolveParam(m[1], example)
|
||||
const count = parseInt(resolveParam(m[2], example), 10)
|
||||
world.memoDb.addTopic(room, count)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topic has follower count',
|
||||
pattern: /^the topic "([^"]+)" has (\S+) followers?$/,
|
||||
run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const followerCount = parseInt(resolveParam(m[2], example), 10)
|
||||
world.memoDb.setTopicFollowerCount(room, followerCount)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topic was last posted at',
|
||||
pattern: /^the topic "([^"]+)" was last posted at (\S+)$/,
|
||||
run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const lastSeen = parseInt(resolveParam(m[2], example), 10)
|
||||
world.memoDb.setTopicLastSeen(room, lastSeen)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'set current time',
|
||||
pattern: /^the current time is (\S+)$/,
|
||||
run (m, example, world) {
|
||||
world.currentTime = parseInt(resolveParam(m[1], example), 10)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'API serves post in topic with address and text',
|
||||
pattern: /^the psf-memo-db API serves a post with txid (.+) in the topic "([^"]+)" authored by the address (.+) with text (.+)$/,
|
||||
@@ -2196,7 +2240,7 @@ const handlers = [
|
||||
},
|
||||
{
|
||||
name: 'topics page shows topic count',
|
||||
pattern: /^the topics page shows the topic (<topic>) with (<count>) posts$/,
|
||||
pattern: /^the topics page shows the topic "?([^"]+?)"? with (\S+) posts$/,
|
||||
run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const expected = parseInt(resolveParam(m[2], example), 10)
|
||||
@@ -2209,6 +2253,34 @@ const handlers = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topics page shows topic follower count',
|
||||
pattern: /^the topics page shows the topic "?([^"]+?)"? with (\S+) followers?$/,
|
||||
run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const expected = parseInt(resolveParam(m[2], example), 10)
|
||||
const topic = world.topicDiscoveryPage.getTopic(room)
|
||||
if (!topic) {
|
||||
throw new Error(`Topic ${room} is not shown on the topics page.`)
|
||||
}
|
||||
if (topic.followerCount !== expected) {
|
||||
throw new Error(`Expected ${room} to have ${expected} followers, got ${topic.followerCount}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topics page shows most recent post label',
|
||||
pattern: /^the topics page shows the most recent post for the topic "([^"]+)" as "(.+)"$/,
|
||||
run (m, example, world) {
|
||||
const room = resolveParam(m[1], example)
|
||||
const expected = resolveParam(m[2], example)
|
||||
const now = world.currentTime ?? Date.now()
|
||||
const actual = world.topicDiscoveryPage.getLastSeenLabel(room, now)
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Expected ${room} most recent post "${expected}", got "${actual}".`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'click topic',
|
||||
pattern: /^I click the topic (<topic>)$/,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
// Local libraries
|
||||
import MemoDb from '../../../services/memo-db'
|
||||
import TopicDiscoveryPage from '../../../services/topic-discovery-page'
|
||||
import { relativeTime } from '../../../services/relative-time'
|
||||
import '../../../App.css'
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
@@ -101,9 +102,12 @@ function Topics (props) {
|
||||
key={topic.room}
|
||||
action
|
||||
onClick={() => handleClick(topic.room)}
|
||||
className='d-flex justify-content-between align-items-center'
|
||||
>
|
||||
#{topic.room}{' '}
|
||||
<span className='text-muted'>({topic.postCount} posts)</span>
|
||||
<span className='topic-name'>#{topic.room}</span>
|
||||
<span className='topic-last-seen text-muted'>{relativeTime(topic.lastSeen, Date.now())}</span>
|
||||
<span className='topic-post-count text-muted'>{topic.postCount} posts</span>
|
||||
<span className='topic-follower-count text-muted'>{topic.followerCount} followers</span>
|
||||
</ListGroup.Item>
|
||||
))}
|
||||
</ListGroup>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/*
|
||||
Format the time since a topic's most recent post as a short label.
|
||||
|
||||
Inputs are epoch milliseconds: `lastSeen` is the room's most recent post
|
||||
time (0 when the room has no posts) and `now` is the current time. The
|
||||
labels match the topics page contract:
|
||||
- no posts -> "No posts"
|
||||
- less than an hour -> "Less than an hour ago"
|
||||
- under 24 hours -> "N hours ago" (singular "1 hour ago")
|
||||
- 24 hours or more -> "N days ago" (singular "1 day ago")
|
||||
*/
|
||||
|
||||
const HOUR_MS = 60 * 60 * 1000
|
||||
const DAY_MS = 24 * HOUR_MS
|
||||
|
||||
function relativeTime (lastSeen, now = Date.now()) {
|
||||
if (!lastSeen) return 'No posts'
|
||||
|
||||
const elapsed = now - lastSeen
|
||||
if (elapsed < HOUR_MS) return 'Less than an hour ago'
|
||||
|
||||
if (elapsed < DAY_MS) {
|
||||
const hours = Math.floor(elapsed / HOUR_MS)
|
||||
return hours === 1 ? '1 hour ago' : `${hours} hours ago`
|
||||
}
|
||||
|
||||
const days = Math.floor(elapsed / DAY_MS)
|
||||
return days === 1 ? '1 day ago' : `${days} days ago`
|
||||
}
|
||||
|
||||
module.exports = { relativeTime }
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
const PaginatedPage = require('./paginated-page')
|
||||
const { relativeTime } = require('./relative-time')
|
||||
|
||||
const TOPICS_PATH = '/topics'
|
||||
|
||||
@@ -24,6 +25,15 @@ class TopicDiscoveryPage extends PaginatedPage {
|
||||
return this.topics.find((topic) => topic.room === room) || null
|
||||
}
|
||||
|
||||
// Label describing how long ago the room's most recent post was, computed
|
||||
// from the API's lastSeen timestamp. Returns null when the topic is not
|
||||
// loaded. `now` is injectable so the label is deterministic in tests.
|
||||
getLastSeenLabel (room, now = Date.now()) {
|
||||
const topic = this.getTopic(room)
|
||||
if (!topic) return null
|
||||
return relativeTime(topic.lastSeen, now)
|
||||
}
|
||||
|
||||
openTopic (room) {
|
||||
const path = TopicDiscoveryPage.topicFeedPath(room)
|
||||
this.navigate(path)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
Unit tests for the relative-time formatter used by the topics page.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { relativeTime } = require('../../src/services/relative-time')
|
||||
|
||||
const NOW = 1800000000000
|
||||
const HOUR = 60 * 60 * 1000
|
||||
const DAY = 24 * HOUR
|
||||
|
||||
test('returns "No posts" when there is no last-seen time', () => {
|
||||
assert.equal(relativeTime(0, NOW), 'No posts')
|
||||
assert.equal(relativeTime(null, NOW), 'No posts')
|
||||
assert.equal(relativeTime(undefined, NOW), 'No posts')
|
||||
})
|
||||
|
||||
test('returns "Less than an hour ago" for under an hour', () => {
|
||||
assert.equal(relativeTime(NOW - 1, NOW), 'Less than an hour ago')
|
||||
assert.equal(relativeTime(NOW - (HOUR - 1), NOW), 'Less than an hour ago')
|
||||
})
|
||||
|
||||
test('returns "1 hour ago" at exactly one hour', () => {
|
||||
assert.equal(relativeTime(NOW - HOUR, NOW), '1 hour ago')
|
||||
})
|
||||
|
||||
test('returns whole hours under a day', () => {
|
||||
assert.equal(relativeTime(NOW - 2 * HOUR, NOW), '2 hours ago')
|
||||
assert.equal(relativeTime(NOW - 5 * HOUR, NOW), '5 hours ago')
|
||||
assert.equal(relativeTime(NOW - 23 * HOUR, NOW), '23 hours ago')
|
||||
})
|
||||
|
||||
test('returns "1 day ago" at exactly one day', () => {
|
||||
assert.equal(relativeTime(NOW - DAY, NOW), '1 day ago')
|
||||
})
|
||||
|
||||
test('returns whole days at or beyond a day', () => {
|
||||
assert.equal(relativeTime(NOW - 2 * DAY, NOW), '2 days ago')
|
||||
assert.equal(relativeTime(NOW - 30 * DAY, NOW), '30 days ago')
|
||||
})
|
||||
|
||||
test('floors partial hours and days', () => {
|
||||
assert.equal(relativeTime(NOW - (5 * HOUR + 59 * 60 * 1000), NOW), '5 hours ago')
|
||||
assert.equal(relativeTime(NOW - (2 * DAY + 23 * HOUR), NOW), '2 days ago')
|
||||
})
|
||||
@@ -110,6 +110,34 @@ test('getTopic returns null when the topic is not loaded', async () => {
|
||||
assert.equal(page.getTopic('bitcoin'), null)
|
||||
})
|
||||
|
||||
test('getLastSeenLabel formats the topic last-seen time', async () => {
|
||||
const page = new TopicDiscoveryPage({
|
||||
memoDb: makeMemoDb({ topics: [{ room: 'bitcoin', postCount: 1, lastSeen: 1799998200000 }] })
|
||||
})
|
||||
|
||||
await page.load()
|
||||
|
||||
assert.equal(page.getLastSeenLabel('bitcoin', 1800000000000), 'Less than an hour ago')
|
||||
})
|
||||
|
||||
test('getLastSeenLabel returns "No posts" for a topic with no posts', async () => {
|
||||
const page = new TopicDiscoveryPage({
|
||||
memoDb: makeMemoDb({ topics: [{ room: 'lone', postCount: 0, lastSeen: 0 }] })
|
||||
})
|
||||
|
||||
await page.load()
|
||||
|
||||
assert.equal(page.getLastSeenLabel('lone', 1800000000000), 'No posts')
|
||||
})
|
||||
|
||||
test('getLastSeenLabel returns null for an unknown topic', async () => {
|
||||
const page = new TopicDiscoveryPage({ memoDb: makeMemoDb({ topics: [] }) })
|
||||
|
||||
await page.load()
|
||||
|
||||
assert.equal(page.getLastSeenLabel('missing', 1800000000000), null)
|
||||
})
|
||||
|
||||
test('openTopic navigates to the encoded topic feed path', () => {
|
||||
const calls = []
|
||||
const page = new TopicDiscoveryPage({
|
||||
|
||||
Reference in New Issue
Block a user