From 2a3e7425326f997113fa502fd7499079f4e3a2b4 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Thu, 3 Sep 2026 21:44:01 -0700 Subject: [PATCH 1/3] Pass appData to Profile route so Follow/Mute buttons receive the wallet By specifier. --- psf-memo-client/src/components/app-body/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/psf-memo-client/src/components/app-body/index.js b/psf-memo-client/src/components/app-body/index.js index dc07b5e..a482d0f 100644 --- a/psf-memo-client/src/components/app-body/index.js +++ b/psf-memo-client/src/components/app-body/index.js @@ -50,7 +50,7 @@ function AppBody (props) { } /> } /> } /> - } /> + } /> } /> } /> } /> From bc07eda9163fc248da2efba5845fcf3e2547131c Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 4 Sep 2026 06:39:31 -0700 Subject: [PATCH 2/3] Spec GET /topics ordering by most recent post By specifier. --- psf-memo-db/specs/topic-read.feature | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/psf-memo-db/specs/topic-read.feature b/psf-memo-db/specs/topic-read.feature index c73415e..e50d643 100644 --- a/psf-memo-db/specs/topic-read.feature +++ b/psf-memo-db/specs/topic-read.feature @@ -2,7 +2,7 @@ # {"version":1,"tested_at":"2026-08-28T15:47:38.414053749Z","feature_name":"Topic Read","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-db/specs/topic-read.feature","background_hash":"922fd45b8606f723073833f0aaca0e6d92b03a263a6b54c60490843e0ac41cc0","implementation_hash":"unknown","scenarios":[{"index":0,"name":"Topic Read - 1 GET /topics lists distinct topics with their post counts","scenario_hash":"a425a0cbf18c7e7fcb7d01c3571556d4c184b013ad7990e724d95d8d100eebb9","mutation_count":8,"result":{"Total":8,"Killed":8,"Survived":0,"Errors":0},"tested_at":"2026-08-28T15:39:33.364455310Z"},{"index":1,"name":"Topic Read - 2 GET /topics/:room/posts returns the posts for a topic sorted by block height descending","scenario_hash":"cffb702052b0df29aa87510e0c45dd01b83c4607e7ce915bc0e660d601f79cce","mutation_count":4,"result":{"Total":4,"Killed":4,"Survived":0,"Errors":0},"tested_at":"2026-08-28T15:39:33.364455310Z"}]} # acceptance-mutation-manifest-end -# Scenarios: Topic Read - 1, Topic Read - 2, Topic Read - 3, Topic Read - 4 +# Scenarios: Topic Read - 1, Topic Read - 2, Topic Read - 3, Topic Read - 4, Topic Read - 5 # # The indexer stores topic activity in the rooms store. Topic messages are # keyed `${room}:${txid}` with type 'post'; topic follows are keyed @@ -15,13 +15,13 @@ # bitcoin:post-200 { room: bitcoin, txid: post-200, type: post, blockHeight: 200 } # bitcoin:addr-f { room: bitcoin, addr: addr-f, type: follow, unfollow: false } # cash:post-250 { room: cash, txid: post-250, type: post, blockHeight: 250 } -# dev:post-100 { room: dev, txid: post-100, type: post, blockHeight: 100 } +# dev:post-400 { room: dev, txid: post-400, type: post, blockHeight: 400 } # lone:addr-f { room: lone, addr: addr-f, type: follow, unfollow: false } # posts store: # post-300 { txid: post-300, addr: addr-a, text: hello bitcoin, blockHeight: 300 } # post-200 { txid: post-200, addr: addr-b, text: bitcoin again, blockHeight: 200 } # post-250 { txid: post-250, addr: addr-a, text: cash rules, blockHeight: 250 } -# post-100 { txid: post-100, addr: addr-c, text: dev stuff, blockHeight: 100 } +# post-400 { txid: post-400, addr: addr-c, text: dev stuff, blockHeight: 400 } Feature: Topic Read Background: @@ -63,3 +63,11 @@ Feature: Topic Read Scenario: Topic Read - 4 GET /topics/:room/posts returns no posts for a topic with no posts When the client requests /topics/lone/posts Then the response contains no posts + + Scenario Outline: Topic Read - 5 GET /topics orders topics by most recent post + When the client requests /topics + Then the response lists topics in order + + Examples: + | expected_order | + | dev,bitcoin,cash,lone | From f45f2f9e2a94afd1f6c4aa79ccfa9d4284a13909 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Fri, 4 Sep 2026 06:43:28 -0700 Subject: [PATCH 3/3] Order GET /topics by most recent post By coder. --- psf-memo-db/acceptance/lib/handlers.js | 15 ++++++++++-- psf-memo-db/src/adapters/topic-query.js | 24 +++++++++++++------ .../property/topic-query.property.test.js | 16 ++++++++++--- .../test/unit/adapters/topic-query.unit.js | 17 +++++++------ 4 files changed, 53 insertions(+), 19 deletions(-) diff --git a/psf-memo-db/acceptance/lib/handlers.js b/psf-memo-db/acceptance/lib/handlers.js index 91069a6..14006e1 100644 --- a/psf-memo-db/acceptance/lib/handlers.js +++ b/psf-memo-db/acceptance/lib/handlers.js @@ -373,7 +373,7 @@ async function loadTopicsWithPosts (world) { { key: 'bitcoin:post-200', room: 'bitcoin', txid: 'post-200', type: 'post', blockHeight: 200 }, { key: 'bitcoin:addr-f', room: 'bitcoin', addr: 'addr-f', type: 'follow', unfollow: false }, { key: 'cash:post-250', room: 'cash', txid: 'post-250', type: 'post', blockHeight: 250 }, - { key: 'dev:post-100', room: 'dev', txid: 'post-100', type: 'post', blockHeight: 100 }, + { key: 'dev:post-400', room: 'dev', txid: 'post-400', type: 'post', blockHeight: 400 }, { key: 'lone:addr-f', room: 'lone', addr: 'addr-f', type: 'follow', unfollow: false } ] @@ -381,7 +381,7 @@ async function loadTopicsWithPosts (world) { 'post-300': { addr: 'addr-a', text: 'hello bitcoin', seen: 1, blockHeight: 300 }, 'post-200': { addr: 'addr-b', text: 'bitcoin again', seen: 2, blockHeight: 200 }, 'post-250': { addr: 'addr-a', text: 'cash rules', seen: 3, blockHeight: 250 }, - 'post-100': { addr: 'addr-c', text: 'dev stuff', seen: 4, blockHeight: 100 } + 'post-400': { addr: 'addr-c', text: 'dev stuff', seen: 4, blockHeight: 400 } } for (const entry of roomEntries) { @@ -868,6 +868,17 @@ const handlers = [ } } }, + { + name: 'response lists topics in order', + pattern: /^the response lists topics in order ()$/, + run (m, example, world) { + const expected = resolveParam(m[1], example).split(',').map((s) => s.trim()) + const actual = world.getLastResponse().topics.map((t) => t.room) + if (expected.join(',') !== actual.join(',')) { + throw new Error(`Expected topics ${expected.join(',')}, got ${actual.join(',')}`) + } + } + }, { name: 'request topic posts', pattern: /^the client requests \/topics\/([^/]+)\/posts(?: with limit () and offset ())?$/, diff --git a/psf-memo-db/src/adapters/topic-query.js b/psf-memo-db/src/adapters/topic-query.js index 07f6d8d..607977a 100644 --- a/psf-memo-db/src/adapters/topic-query.js +++ b/psf-memo-db/src/adapters/topic-query.js @@ -42,21 +42,31 @@ class TopicQuery { } async listTopics () { - const counts = new Map() + const topics = new Map() for await (const [key, value] of this.roomsDb.iterator()) { const room = this.roomFromKey(key, value) - if (!counts.has(room)) { - counts.set(room, 0) + if (!topics.has(room)) { + topics.set(room, { postCount: 0, lastHeight: 0 }) } + const topic = topics.get(room) if (value?.type === 'post') { - counts.set(room, counts.get(room) + 1) + topic.postCount++ + const height = value?.blockHeight ?? 0 + if (height > topic.lastHeight) { + topic.lastHeight = height + } } } - return Array.from(counts.entries()) - .map(([room, postCount]) => ({ room, postCount })) - .sort((a, b) => a.room.localeCompare(b.room)) + return Array.from(topics.entries()) + .map(([room, { postCount, lastHeight }]) => ({ room, postCount, lastHeight })) + .sort((a, b) => { + if (b.lastHeight !== a.lastHeight) { + return b.lastHeight - a.lastHeight + } + return a.room.localeCompare(b.room) + }) } async getTopicPostTxids (room, { limit, offset }) { diff --git a/psf-memo-db/test/property/topic-query.property.test.js b/psf-memo-db/test/property/topic-query.property.test.js index fbba33a..82a87b3 100644 --- a/psf-memo-db/test/property/topic-query.property.test.js +++ b/psf-memo-db/test/property/topic-query.property.test.js @@ -86,7 +86,7 @@ function fixtureGen () { } } -test('listTopics conserves post counts and returns rooms sorted by name', async () => { +test('listTopics conserves post counts and returns rooms sorted by most recent post', async () => { await forAll( fixtureGen(), async ({ entries, rooms }) => { @@ -97,8 +97,18 @@ test('listTopics conserves post counts and returns rooms sorted by name', async const totalPosts = topics.reduce((sum, t) => sum + t.postCount, 0) if (totalPosts !== postEntries.length) return false - const expectedRooms = [...new Set(entries.map((e) => e.value.room))].sort((a, b) => a.localeCompare(b)) - if (JSON.stringify(topics.map((t) => t.room)) !== JSON.stringify(expectedRooms)) return false + const expectedTopics = [...new Set(entries.map((e) => e.value.room))] + .map((room) => { + const heights = entries + .filter((e) => e.value.room === room && e.value.type === 'post') + .map((e) => e.value.blockHeight ?? 0) + return { room, lastHeight: heights.length ? Math.max(...heights) : 0 } + }) + .sort((a, b) => { + if (b.lastHeight !== a.lastHeight) return b.lastHeight - a.lastHeight + return a.room.localeCompare(b.room) + }) + if (JSON.stringify(topics.map((t) => t.room)) !== JSON.stringify(expectedTopics.map((t) => t.room))) return false for (const topic of topics) { const roomPosts = postEntries.filter((e) => e.value.room === topic.room).length diff --git a/psf-memo-db/test/unit/adapters/topic-query.unit.js b/psf-memo-db/test/unit/adapters/topic-query.unit.js index f10e444..f33fdb5 100644 --- a/psf-memo-db/test/unit/adapters/topic-query.unit.js +++ b/psf-memo-db/test/unit/adapters/topic-query.unit.js @@ -108,23 +108,26 @@ describe('#TopicQuery', () => { const result = await uut.listTopics() assert.deepEqual(result, [ - { room: 'bitcoin', postCount: 2 }, - { room: 'cash', postCount: 1 }, - { room: 'dev', postCount: 1 }, - { room: 'lone', postCount: 0 } + { room: 'bitcoin', postCount: 2, lastHeight: 300 }, + { room: 'cash', postCount: 1, lastHeight: 250 }, + { room: 'dev', postCount: 1, lastHeight: 100 }, + { room: 'lone', postCount: 0, lastHeight: 0 } ]) }) - it('should sort topics by room name', async () => { + it('should sort topics by most recent post descending', async () => { async function * mockRooms () { yield ['zoo:post-1', { room: 'zoo', txid: 'post-1', type: 'post', blockHeight: 1 }] - yield ['alpha:post-1', { room: 'alpha', txid: 'post-1', type: 'post', blockHeight: 1 }] + yield ['alpha:post-1', { room: 'alpha', txid: 'post-1', type: 'post', blockHeight: 2 }] } roomsDb.iterator.returns(mockRooms()) const result = await uut.listTopics() - assert.deepEqual(result.map((t) => t.room), ['alpha', 'zoo']) + assert.deepEqual(result.map((t) => ({ room: t.room, lastHeight: t.lastHeight })), [ + { room: 'alpha', lastHeight: 2 }, + { room: 'zoo', lastHeight: 1 } + ]) }) })