Merge topic-actions implementation from architect

Merge swarmforge-architect into master for task topic-actions.

By specifier.
This commit is contained in:
Chris Troutner
2026-08-28 13:17:10 -07:00
30 changed files with 2114 additions and 29 deletions
+202 -5
View File
@@ -38,6 +38,9 @@ const ProfilePage = require('../../src/services/profile-page')
const ThreadPage = require('../../src/services/thread-page')
const TopicDiscoveryPage = require('../../src/services/topic-discovery-page')
const TopicFeedPage = require('../../src/services/topic-feed-page')
const MemoTopicFollow = require('../../src/services/memo-topic-follow')
const MemoTopicPost = require('../../src/services/memo-topic-post')
const TopicPostPage = require('../../src/services/topic-post-page')
const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX
@@ -47,6 +50,9 @@ const MEMO_SET_AVATAR_URL_PREFIX = MemoSetAvatarUrl.MEMO_SET_AVATAR_URL_PREFIX
const MEMO_LIKE_PREFIX = MemoLike.MEMO_LIKE_PREFIX
const MEMO_FOLLOW_PREFIX = MemoFollow.MEMO_FOLLOW_PREFIX
const MEMO_UNFOLLOW_PREFIX = MemoFollow.MEMO_UNFOLLOW_PREFIX
const MEMO_TOPIC_MESSAGE_PREFIX = MemoTopicPost.MEMO_TOPIC_MESSAGE_PREFIX
const MEMO_TOPIC_FOLLOW_PREFIX = MemoTopicFollow.MEMO_TOPIC_FOLLOW_PREFIX
const MEMO_TOPIC_UNFOLLOW_PREFIX = MemoTopicFollow.MEMO_TOPIC_UNFOLLOW_PREFIX
// Default author address used by Gherkin steps that refer to "the author address".
const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc'
@@ -98,11 +104,13 @@ function makeProfiles () {
const bios = {}
const avatarUrls = {}
const following = {}
const topicFollowing = {}
return {
names,
bios,
avatarUrls,
following,
topicFollowing,
setName: (addr, name) => { names[addr] = name },
getName: (addr) => names[addr] || null,
setBio: (addr, bio) => { bios[addr] = bio },
@@ -113,7 +121,12 @@ function makeProfiles () {
if (!following[selfAddr]) following[selfAddr] = {}
following[selfAddr][targetAddr] = isFollowing
},
getFollowState: (selfAddr, targetAddr) => following[selfAddr]?.[targetAddr] || false
getFollowState: (selfAddr, targetAddr) => following[selfAddr]?.[targetAddr] || false,
setTopicFollowState: (selfAddr, room, isFollowing) => {
if (!topicFollowing[selfAddr]) topicFollowing[selfAddr] = {}
topicFollowing[selfAddr][room] = isFollowing
},
getTopicFollowState: (selfAddr, room) => topicFollowing[selfAddr]?.[room] || false
}
}
@@ -136,6 +149,7 @@ function makeMemoDb () {
const topics = []
const topicPosts = {}
const topicCounts = new Map()
const topicFollow = new Map()
return {
posts,
@@ -171,6 +185,20 @@ function makeMemoDb () {
setFollowState (followerAddr, followeeAddr, following) {
followState[`${followerAddr}:${followeeAddr}`] = following
},
setTopicFollowState (addr, room, following) {
if (!topicFollow.has(room)) topicFollow.set(room, new Map())
topicFollow.get(room).set(addr, following)
},
async getTopicFollowState (room, addr) {
return topicFollow.get(room)?.get(addr) || false
},
async getTopicFollowers (room) {
const addrs = []
for (const [addr, following] of (topicFollow.get(room) || new Map()).entries()) {
if (following) addrs.push(addr)
}
return addrs
},
async getRecentPosts ({ limit = 100, offset = 0 } = {}) {
const page = posts.slice(offset, offset + limit)
return { posts: page, pagination: { total: posts.length, limit, offset, hasMore: offset + page.length < posts.length } }
@@ -212,6 +240,7 @@ function createWorld () {
const memoReply = new MemoReply({ wallet, thread })
const memoLike = new MemoLike({ wallet, feed })
const memoFollow = new MemoFollow({ wallet, profiles })
const memoTopicFollow = new MemoTopicFollow({ wallet, profiles })
const memoDb = makeMemoDb()
const world = {
@@ -222,6 +251,7 @@ function createWorld () {
memoReply,
memoLike,
memoFollow,
memoTopicFollow,
memoDb,
currentPath: null,
menuOpen: false,
@@ -326,6 +356,13 @@ function findDisplayedPost (txid, world) {
return null
}
// True when the currently displayed page is a topic feed (used to dispatch the
// shared "Follow/Unfollow button" steps between the profile page and the topic
// feed page).
function isTopicFeedActive (world) {
return Boolean(world.currentPath && String(world.currentPath).startsWith('/topics/'))
}
// Handler registry. Each entry: { pattern, run }.
// run receives (match, exampleStore, world, step).
const handlers = [
@@ -1340,14 +1377,22 @@ const handlers = [
name: 'click Follow button',
pattern: /^I click the Follow button$/,
async run (m, example, world) {
await world.profilePage.follow()
if (isTopicFeedActive(world)) {
await world.topicFeedPage.follow()
} else {
await world.profilePage.follow()
}
}
},
{
name: 'click Unfollow button',
pattern: /^I click the Unfollow button$/,
async run (m, example, world) {
await world.profilePage.unfollow()
if (isTopicFeedActive(world)) {
await world.topicFeedPage.unfollow()
} else {
await world.profilePage.unfollow()
}
}
},
{
@@ -1471,12 +1516,23 @@ const handlers = [
},
{
name: 'open topic feed',
pattern: /^I open the topic feed for "?(<topic>|[^"]+)"?$/,
pattern: /^I open the topic feed for (?:the topic )?"?(<topic>|[^"]+)"?$/,
async run (m, example, world) {
const room = resolveParam(m[1], example)
world.topicFeedPage = new TopicFeedPage({ memoDb: world.memoDb, room })
const myAddr = world.wallet.walletInfo.cashAddress
world.topicFeedPage = new TopicFeedPage({
memoDb: world.memoDb,
room,
myAddr,
memoTopicFollow: world.memoTopicFollow
})
await world.topicFeedPage.load()
world.currentPath = TopicFeedPage.topicFeedPath(room)
// Set up the topic post composer for this room so topic messages can be
// composed and broadcast, reflecting new posts onto the shared feed.
const memoTopicPost = new MemoTopicPost({ wallet: world.wallet, room, feed: world.feed })
world.topicPostPage = new TopicPostPage({ memoTopicPost })
}
},
{
@@ -1503,6 +1559,147 @@ const handlers = [
throw new Error(`Expected topic feed to be empty, but found ${Array.isArray(posts) ? posts.length : 'non-array'} posts.`)
}
}
},
{
name: 'API reports I do not follow topic',
pattern: /^the psf-memo-db API reports that I do not follow the topic (<topic>)$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const myAddr = world.wallet.walletInfo.cashAddress
world.memoDb.setTopicFollowState(myAddr, room, false)
}
},
{
name: 'API reports I follow topic',
pattern: /^the psf-memo-db API reports that I follow the topic (<topic>)$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const myAddr = world.wallet.walletInfo.cashAddress
world.memoDb.setTopicFollowState(myAddr, room, true)
}
},
{
name: 'topic feed page shows Follow button',
pattern: /^the topic feed page shows a Follow button$/,
run (m, example, world) {
if (!world.topicFeedPage) throw new Error('No topic feed page is loaded.')
if (!world.topicFeedPage.canFollow()) throw new Error('Topic feed cannot show a Follow button.')
if (world.topicFeedPage.isFollowing()) throw new Error('Topic feed shows Unfollow, but Follow was expected.')
}
},
{
name: 'topic feed page shows Unfollow button',
pattern: /^the topic feed page shows an Unfollow button$/,
run (m, example, world) {
if (!world.topicFeedPage) throw new Error('No topic feed page is loaded.')
if (!world.topicFeedPage.canFollow()) throw new Error('Topic feed cannot show an Unfollow button.')
if (!world.topicFeedPage.isFollowing()) throw new Error('Topic feed shows Follow, but Unfollow was expected.')
}
},
{
name: 'broadcasts topic-follow prefix',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo topic-follow prefix for the topic (<topic>)$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.')
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_TOPIC_FOLLOW_PREFIX) {
throw new Error(`Expected Memo topic-follow prefix ${MEMO_TOPIC_FOLLOW_PREFIX}, got "${last.prefix}".`)
}
if (last.msg !== room) {
throw new Error(`Broadcast topic-follow payload did not match topic ${room}.`)
}
}
},
{
name: 'broadcasts topic-unfollow prefix',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo topic-unfollow prefix for the topic (<topic>)$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.')
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_TOPIC_UNFOLLOW_PREFIX) {
throw new Error(`Expected Memo topic-unfollow prefix ${MEMO_TOPIC_UNFOLLOW_PREFIX}, got "${last.prefix}".`)
}
if (last.msg !== room) {
throw new Error(`Broadcast topic-unfollow payload did not match topic ${room}.`)
}
}
},
{
name: 'compose topic message',
pattern: /^I compose a topic message with the text "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
if (!(param in example)) throw new Error(`Missing example value for "${param}"`)
world.topicPostPage.setInput(example[param])
}
},
{
name: 'submit topic message',
pattern: /^I submit the topic message$/,
async run (m, example, world) {
await world.topicPostPage.submit()
}
},
{
name: 'broadcasts topic-message prefix',
pattern: /^the app broadcasts an OP_RETURN transaction with the Memo topic-message prefix for the topic (<topic>)$/,
run (m, example, world) {
const room = resolveParam(m[1], example)
const broadcasts = world.wallet.broadcasts
if (!broadcasts.length) throw new Error('No OP_RETURN transaction was broadcast.')
const last = broadcasts[broadcasts.length - 1]
if (last.prefix !== MEMO_TOPIC_MESSAGE_PREFIX) {
throw new Error(`Expected Memo topic-message prefix ${MEMO_TOPIC_MESSAGE_PREFIX}, got "${last.prefix}".`)
}
const expectedPayload = room + world.topicPostPage.input
if (last.msg !== expectedPayload) {
throw new Error(`Broadcast topic-message payload did not match ${room} + input.`)
}
}
},
{
name: 'topic feed shows new post from my address',
pattern: /^the topic feed shows a new post from my address with the text "(.+)"$/,
run (m, example, world) {
const expectedText = resolveParam(m[1], example)
const myAddress = world.wallet.walletInfo.cashAddress
const found = world.feed.posts.find((p) => p.text === expectedText && p.address === myAddress)
if (!found) throw new Error(`Topic feed does not show the new post with text "${expectedText}".`)
}
},
{
name: 'topic post composer shows validation error',
pattern: /^the topic post composer shows a validation error$/,
run (m, example, world) {
if (world.topicPostPage.submitError !== 'topic_post_validation') {
throw new Error(`Expected topic_post_validation, got ${world.topicPostPage.submitError}.`)
}
}
},
{
name: 'topic post composer shows length error',
pattern: /^the topic post composer shows a length error$/,
run (m, example, world) {
if (world.topicPostPage.submitError !== 'topic_post_length') {
throw new Error(`Expected topic_post_length, got ${world.topicPostPage.submitError}.`)
}
}
},
{
name: 'topic post composer remaining byte count',
pattern: /^the topic post composer shows a remaining byte count of (<count>)$/,
run (m, example, world) {
const expected = parseInt(resolveParam(m[1], example), 10)
if (Number.isNaN(expected)) throw new Error(`Invalid expected count for "${m[1]}".`)
const actual = world.topicPostPage.remainingCount()
if (actual !== expected) {
throw new Error(`Expected ${expected} remaining bytes, got ${actual}.`)
}
}
}
]
@@ -1,3 +1,7 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-08-28T20:08:33.247012122Z","feature_name":"Topic Follow","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/topic-follow.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[]}
# acceptance-mutation-manifest-end
# Scenarios: Topic Follow - 1, Topic Follow - 2, Topic Follow - 3, Topic Follow - 4
#
# The topic feed page has a Follow/Unfollow button that broadcasts a Memo
+4
View File
@@ -1,3 +1,7 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-08-28T20:08:37.622959822Z","feature_name":"Topic Post","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/topic-post.feature","background_hash":"e1d5f81f1ed083ac6934c429ca3cb4a0f8d4dac44c2eaa45c0960920bde2c017","implementation_hash":"unknown","scenarios":[]}
# acceptance-mutation-manifest-end
# Scenarios: Topic Post - 1, Topic Post - 2, Topic Post - 3, Topic Post - 4
#
# The topic feed page has a composer that broadcasts a Memo topic message
@@ -1,21 +1,26 @@
/*
Display the posts for a single Memo topic.
Display the posts for a single Memo topic and provide topic post / follow
controls.
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { Container, Row, Col, Spinner, Button } from 'react-bootstrap'
import { Container, Row, Col, Spinner, Button, Form } from 'react-bootstrap'
import { useParams } from 'react-router-dom'
// Local libraries
import MemoDb from '../../../services/memo-db'
import TopicFeedPage from '../../../services/topic-feed-page'
import MemoTopicPost from '../../../services/memo-topic-post'
import TopicPostPage from '../../../services/topic-post-page'
import MemoTopicFollow from '../../../services/memo-topic-follow'
import PostFeedItem from '../../post-feed/post-feed-item'
import PostThreadModal from '../../post-thread-modal'
import {
collectPostAddrs,
loadThreadProfiles
} from '../../post-thread-modal/thread-profiles'
import { byteLength } from '../../../services/utf8'
import '../../../App.css'
import '../../post-feed/post-feed.css'
@@ -24,6 +29,8 @@ const PAGE_SIZE = 100
function TopicFeed (props) {
const { appData } = props
const { room } = useParams()
const myAddr = appData?.wallet?.walletInfo?.cashAddress
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [posts, setPosts] = useState([])
@@ -33,6 +40,14 @@ function TopicFeed (props) {
const [threadTxid, setThreadTxid] = useState(null)
const [showThreadModal, setShowThreadModal] = useState(false)
const [composerInput, setComposerInput] = useState('')
const [composerErr, setComposerErr] = useState('')
const [postingTopic, setPostingTopic] = useState(false)
const [isFollowing, setIsFollowing] = useState(false)
const [followers, setFollowers] = useState([])
const [followSubmitting, setFollowSubmitting] = useState(false)
const openThread = (txid) => {
setThreadTxid(txid)
setShowThreadModal(true)
@@ -44,14 +59,23 @@ function TopicFeed (props) {
}
useEffect(() => {
const loadPosts = async () => {
const loadFeed = async () => {
setLoading(true)
setError(null)
setProfiles({})
try {
const memoDb = new MemoDb()
const page = new TopicFeedPage({ memoDb, room })
const memoTopicFollow = new MemoTopicFollow({
wallet: appData?.wallet,
profiles: appData?.profiles
})
const page = new TopicFeedPage({
memoDb,
room,
myAddr,
memoTopicFollow
})
const data = await page.load({ limit: PAGE_SIZE, offset })
const loadedPosts = data.posts || []
@@ -61,18 +85,22 @@ function TopicFeed (props) {
setPosts(loadedPosts)
setProfiles(profileMap)
setPagination(data.pagination || null)
setIsFollowing(data.followState === true)
setFollowers(data.followers || [])
} catch (err) {
setError(err.message || `Failed to load posts for topic ${room}`)
setPosts([])
setProfiles({})
setPagination(null)
setIsFollowing(false)
setFollowers([])
}
setLoading(false)
}
loadPosts()
}, [room, offset])
loadFeed()
}, [room, offset, myAddr, appData?.wallet, appData?.profiles])
const canGoBack = offset > 0
const canGoNext = pagination?.hasMore ?? false
@@ -85,13 +113,140 @@ function TopicFeed (props) {
setOffset((prev) => prev + PAGE_SIZE)
}
const remainingBytes = () => {
return MemoTopicPost.MAX_TOPIC_MESSAGE_BYTES - byteLength(room) - byteLength(composerInput)
}
const handleComposerChange = (event) => {
setComposerInput(event.target.value)
setComposerErr('')
}
const handlePostTopic = async (event) => {
event.preventDefault()
setComposerErr('')
setPostingTopic(true)
try {
const memoTopicPost = new MemoTopicPost({
wallet: appData?.wallet,
room
})
const page = new TopicPostPage({ memoTopicPost })
page.setInput(composerInput)
const result = await page.submit()
if (!result.ok) {
if (result.error === 'topic_post_length') {
setComposerErr(`Topic message is too long. Maximum is ${MemoTopicPost.MAX_TOPIC_MESSAGE_BYTES} bytes.`)
} else if (result.error === 'topic_post_validation') {
setComposerErr('Topic message must not be empty.')
} else if (result.message) {
setComposerErr(`Failed to broadcast: ${result.message}`)
} else {
setComposerErr('Failed to post topic message.')
}
} else {
// Optimistically show the new post at the top of the feed.
const newPost = {
txid: result.txid,
addr: myAddr,
text: composerInput,
seen: Date.now(),
blockHeight: 0,
replyCount: 0,
likeCount: 0
}
setPosts((prev) => [newPost, ...prev])
setComposerInput('')
}
} catch (submitErr) {
setComposerErr(submitErr.message)
} finally {
setPostingTopic(false)
}
}
const handleFollowClick = async () => {
setFollowSubmitting(true)
setError(null)
try {
const memoTopicFollow = new MemoTopicFollow({
wallet: appData?.wallet,
profiles: appData?.profiles
})
const page = new TopicFeedPage({
memoDb: new MemoDb(),
room,
myAddr,
memoTopicFollow
})
await page.follow()
setIsFollowing(true)
if (myAddr && !followers.includes(myAddr)) {
setFollowers((prev) => [...prev, myAddr])
}
} catch (err) {
setError(`Failed to follow topic: ${err.message}`)
}
setFollowSubmitting(false)
}
const handleUnfollowClick = async () => {
setFollowSubmitting(true)
setError(null)
try {
const memoTopicFollow = new MemoTopicFollow({
wallet: appData?.wallet,
profiles: appData?.profiles
})
const page = new TopicFeedPage({
memoDb: new MemoDb(),
room,
myAddr,
memoTopicFollow
})
await page.unfollow()
setIsFollowing(false)
if (myAddr) {
setFollowers((prev) => prev.filter((addr) => addr !== myAddr))
}
} catch (err) {
setError(`Failed to unfollow topic: ${err.message}`)
}
setFollowSubmitting(false)
}
const showComposer = Boolean(myAddr)
const showFollowButton = Boolean(myAddr)
return (
<Container className='topic-feed-page'>
<Row className='justify-content-center'>
<Col lg={8} md={10} xs={12}>
<header className='topic-feed-heading'>
<h1>#{room}</h1>
<p>Posts published in the {room} topic.</p>
<div className='topic-feed-heading-row'>
<div>
<h1>#{room}</h1>
<p>Posts published in the {room} topic.</p>
</div>
{showFollowButton && (
<Button
variant={isFollowing ? 'outline-secondary' : 'primary'}
onClick={isFollowing ? handleUnfollowClick : handleFollowClick}
disabled={followSubmitting}
>
{followSubmitting
? 'Working...'
: (isFollowing ? 'Unfollow' : 'Follow')}
</Button>
)}
</div>
{pagination && posts.length > 0 && (
<span className='topic-feed-count'>
@@ -99,8 +254,42 @@ function TopicFeed (props) {
{pagination.offset + posts.length} of {pagination.total}
</span>
)}
{followers.length > 0 && (
<span className='topic-feed-follower-count'>
{followers.length} follower{followers.length === 1 ? '' : 's'}
</span>
)}
</header>
{showComposer && (
<Form onSubmit={handlePostTopic} className='topic-post-composer mb-4'>
<Form.Group controlId='topic-post-message' className='mb-2'>
<Form.Control
as='textarea'
rows={3}
value={composerInput}
onChange={handleComposerChange}
placeholder={`Post in #${room}...`}
/>
</Form.Group>
<div className='topic-post-composer-footer'>
<p className='topic-post-counter mb-0'>
{remainingBytes()} bytes remaining
</p>
<Button type='submit' variant='primary' disabled={postingTopic}>
{postingTopic ? 'Posting...' : 'Post'}
</Button>
</div>
{composerErr && (
<p className='topic-post-error mt-2'>{composerErr}</p>
)}
</Form>
)}
{error && (
<p className='topic-feed-error'>
{error}
+28 -1
View File
@@ -56,6 +56,33 @@ class MemoDb {
return this.getPage(`/topics/${encodeURIComponent(room)}/posts`, 'getTopicPosts', opts)
}
async getTopicFollowState (room, addr) {
try {
const result = await this.axios.get(
`${config.backend}/topics/${encodeURIComponent(room)}/follow/state`,
{
params: { addr }
}
)
return result.data.following === true
} catch (err) {
console.error('Error in getTopicFollowState()')
throw err
}
}
async getTopicFollowers (room) {
try {
const result = await this.axios.get(
`${config.backend}/topics/${encodeURIComponent(room)}/followers`
)
return result.data.followers || []
} catch (err) {
console.error('Error in getTopicFollowers()')
throw err
}
}
// GET a paginated 'recent' listing endpoint.
async getRecent (path, name, params) {
try {
@@ -121,5 +148,5 @@ class MemoDb {
export default MemoDb
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T15:36:17.287Z","module_hash":"77d15bc46bc63e2ffbd5fa7387b08a74db8e967482626f4f52211ceb60bd0633","functions":[{"id":"func/MemoDb.constructor","name":"MemoDb.constructor","line":9,"end_line":11,"hash":"188825ae5983840d4e1a4df182966ef012297c336ea3b763c87ea1c384654279"},{"id":"func/MemoDb.getRecentProfiles","name":"MemoDb.getRecentProfiles","line":13,"end_line":15,"hash":"cb819c72881b70f719f964c607f64be70b40377297c738897eee495bcf6dff4d"},{"id":"func/MemoDb.getRecentPosts","name":"MemoDb.getRecentPosts","line":17,"end_line":19,"hash":"7c0fd29c2fdc67a27a05a190e2e4294a698a1fe5223d8017ca82c4f890c2d094"},{"id":"func/MemoDb.getProfile","name":"MemoDb.getProfile","line":21,"end_line":23,"hash":"6a250c7205799b40dbe97b8f17ddaee6eace8cb435b0561d9ff8f5eac93d2b20"},{"id":"func/MemoDb.getProfilePic","name":"MemoDb.getProfilePic","line":25,"end_line":27,"hash":"d61dd5aebce32ef28b69ad56f2f7108d6b5f333a4d94cd83f05f01a09fa8f1d7"},{"id":"func/MemoDb.getName","name":"MemoDb.getName","line":29,"end_line":31,"hash":"3f841735b93180c6aeeb14a563bb6d4eb0ca5d395fb960267c8896b06c1e85f3"},{"id":"func/MemoDb.getFollowState","name":"MemoDb.getFollowState","line":33,"end_line":49,"hash":"d62eb85c742b40f2d319230c6ef59e21738063d31bccf1f37ebdc52c20bac994"},{"id":"func/MemoDb.getTopics","name":"MemoDb.getTopics","line":51,"end_line":53,"hash":"8d392b8a4b1bef405a9872c2564c51d9343df7b1c628d016258ee1c165f713ef"},{"id":"func/MemoDb.getTopicPosts","name":"MemoDb.getTopicPosts","line":55,"end_line":57,"hash":"4cc1e13334b5b4a2b72942cbc5f3cce8493254628f7d51da33d7be9c60ef9ff7"},{"id":"func/MemoDb.getRecent","name":"MemoDb.getRecent","line":60,"end_line":71,"hash":"1565fd17dced45fbf1b6d6fd6b8003521129bbd88046ff52a4eb61c17862530d"},{"id":"func/MemoDb.getPage","name":"MemoDb.getPage","line":74,"end_line":85,"hash":"986ec43d7699d642b25a9f4627aa5420cc685a247c3caa4671797778bc84e3b5"},{"id":"func/MemoDb.getLevelResource","name":"MemoDb.getLevelResource","line":88,"end_line":101,"hash":"c2fb7338918b1e69aa8a14f65ae687532c843f0641f04074947731691001e4ea"},{"id":"func/MemoDb.getPostsByAddr","name":"MemoDb.getPostsByAddr","line":103,"end_line":105,"hash":"45bbfef12cd8e35da1d71299153b2e54304664d5e3191c497f105ba17b134431"},{"id":"func/MemoDb.getPostThread","name":"MemoDb.getPostThread","line":107,"end_line":118,"hash":"ed5ef157b457f8d984ff6a9488a4dccf7a37b0ae65adb40005df90508426661f"}]}
// {"version":1,"tested_at":"2026-08-28T18:23:08.799Z","module_hash":"c55696ecbf8f9c776dc025b308cefadf604950952002bc3a2f4a021f9c73085b","functions":[{"id":"func/MemoDb.constructor","name":"MemoDb.constructor","line":9,"end_line":11,"hash":"188825ae5983840d4e1a4df182966ef012297c336ea3b763c87ea1c384654279"},{"id":"func/MemoDb.getRecentProfiles","name":"MemoDb.getRecentProfiles","line":13,"end_line":15,"hash":"cb819c72881b70f719f964c607f64be70b40377297c738897eee495bcf6dff4d"},{"id":"func/MemoDb.getRecentPosts","name":"MemoDb.getRecentPosts","line":17,"end_line":19,"hash":"7c0fd29c2fdc67a27a05a190e2e4294a698a1fe5223d8017ca82c4f890c2d094"},{"id":"func/MemoDb.getProfile","name":"MemoDb.getProfile","line":21,"end_line":23,"hash":"6a250c7205799b40dbe97b8f17ddaee6eace8cb435b0561d9ff8f5eac93d2b20"},{"id":"func/MemoDb.getProfilePic","name":"MemoDb.getProfilePic","line":25,"end_line":27,"hash":"d61dd5aebce32ef28b69ad56f2f7108d6b5f333a4d94cd83f05f01a09fa8f1d7"},{"id":"func/MemoDb.getName","name":"MemoDb.getName","line":29,"end_line":31,"hash":"3f841735b93180c6aeeb14a563bb6d4eb0ca5d395fb960267c8896b06c1e85f3"},{"id":"func/MemoDb.getFollowState","name":"MemoDb.getFollowState","line":33,"end_line":49,"hash":"d62eb85c742b40f2d319230c6ef59e21738063d31bccf1f37ebdc52c20bac994"},{"id":"func/MemoDb.getTopics","name":"MemoDb.getTopics","line":51,"end_line":53,"hash":"8d392b8a4b1bef405a9872c2564c51d9343df7b1c628d016258ee1c165f713ef"},{"id":"func/MemoDb.getTopicPosts","name":"MemoDb.getTopicPosts","line":55,"end_line":57,"hash":"4cc1e13334b5b4a2b72942cbc5f3cce8493254628f7d51da33d7be9c60ef9ff7"},{"id":"func/MemoDb.getTopicFollowState","name":"MemoDb.getTopicFollowState","line":59,"end_line":72,"hash":"4f056870b5dee08dfffd7484f9d94cf1721297ffa4ea629dcc4208a8217b4d24"},{"id":"func/MemoDb.getTopicFollowers","name":"MemoDb.getTopicFollowers","line":74,"end_line":84,"hash":"65c9eff7e2afb949cc46b64f2091eed006d9b6800ca5aee6c7e8793ae13e3e94"},{"id":"func/MemoDb.getRecent","name":"MemoDb.getRecent","line":87,"end_line":98,"hash":"1565fd17dced45fbf1b6d6fd6b8003521129bbd88046ff52a4eb61c17862530d"},{"id":"func/MemoDb.getPage","name":"MemoDb.getPage","line":101,"end_line":112,"hash":"986ec43d7699d642b25a9f4627aa5420cc685a247c3caa4671797778bc84e3b5"},{"id":"func/MemoDb.getLevelResource","name":"MemoDb.getLevelResource","line":115,"end_line":128,"hash":"c2fb7338918b1e69aa8a14f65ae687532c843f0641f04074947731691001e4ea"},{"id":"func/MemoDb.getPostsByAddr","name":"MemoDb.getPostsByAddr","line":130,"end_line":132,"hash":"45bbfef12cd8e35da1d71299153b2e54304664d5e3191c497f105ba17b134431"},{"id":"func/MemoDb.getPostThread","name":"MemoDb.getPostThread","line":134,"end_line":145,"hash":"ed5ef157b457f8d984ff6a9488a4dccf7a37b0ae65adb40005df90508426661f"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,73 @@
/*
Memo topic follow/unfollow behavior: compose, validate, and broadcast a
Memo topic follow (0x6d0d) or unfollow (0x6d0e) action.
A topic follow transaction carries the topic name as plain UTF-8 text. No
cashaddr conversion is needed.
The wallet and an injected profile store are used so this module stays
testable and free of UI/network concerns; environmentally unsuitable I/O
lives behind those small adapter boundaries.
Constants
MEMO_TOPIC_FOLLOW_PREFIX : hex prefix for the Memo "topic follow" action (0x6d0d)
MEMO_TOPIC_UNFOLLOW_PREFIX : hex prefix for the Memo "topic unfollow" action (0x6d0e)
*/
const MEMO_TOPIC_FOLLOW_PREFIX = '6d0d'
const MEMO_TOPIC_UNFOLLOW_PREFIX = '6d0e'
class MemoTopicFollow {
constructor (deps = {}) {
this.wallet = deps.wallet
this.profiles = deps.profiles
}
validate (room) {
if (typeof room !== 'string' || room.trim().length === 0) {
const err = new Error('Topic name is required.')
err.code = 'topic_follow_validation'
throw err
}
}
async follow (room) {
return this._broadcastAction(room, MEMO_TOPIC_FOLLOW_PREFIX, true)
}
async unfollow (room) {
return this._broadcastAction(room, MEMO_TOPIC_UNFOLLOW_PREFIX, false)
}
async _broadcastAction (room, prefix, isFollowing) {
if (!this.wallet) {
throw new Error('Memo topic follow requires a wallet.')
}
this.validate(room)
await this.wallet.getUtxos()
const txid = await this.wallet.sendOpReturn(room, prefix)
this.reflect(txid, room, isFollowing)
return txid
}
reflect (txid, room, isFollowing) {
if (this.profiles && typeof this.profiles.setTopicFollowState === 'function') {
const myAddr = this.wallet?.walletInfo?.cashAddress
this.profiles.setTopicFollowState(myAddr, room, isFollowing)
}
}
}
MemoTopicFollow.MEMO_TOPIC_FOLLOW_PREFIX = MEMO_TOPIC_FOLLOW_PREFIX
MemoTopicFollow.MEMO_TOPIC_UNFOLLOW_PREFIX = MEMO_TOPIC_UNFOLLOW_PREFIX
module.exports = MemoTopicFollow
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T18:19:01.010Z","module_hash":"f6fd04a72273df34505b97c8f790bba83a2d379d8325267f7904e3826749c686","functions":[{"id":"func/MemoTopicFollow.constructor","name":"MemoTopicFollow.constructor","line":21,"end_line":24,"hash":"dd4ee8221c4d849937d901f6383138aefe615763747cc6dab059db9291843b72"},{"id":"func/MemoTopicFollow.validate","name":"MemoTopicFollow.validate","line":26,"end_line":32,"hash":"e8392594d65c9a31e79a52afcdf92a0ddf111296cf24fa108b16d236405c61d4"},{"id":"func/MemoTopicFollow.follow","name":"MemoTopicFollow.follow","line":34,"end_line":36,"hash":"3a066e2f12efb2ec54f642e11d1eefd4e55d733589fe8266d9de8b3c6651265f"},{"id":"func/MemoTopicFollow.unfollow","name":"MemoTopicFollow.unfollow","line":38,"end_line":40,"hash":"cc714d188e5ba49eee63e0dda84a4ebc205af4ee783c346b647f5ac6645d90a2"},{"id":"func/MemoTopicFollow._broadcastAction","name":"MemoTopicFollow._broadcastAction","line":42,"end_line":56,"hash":"d191f210d5505f9bbb29c74fd02afe91f037a6e9c13a9514a4203a40cd28ff5f"},{"id":"func/MemoTopicFollow.reflect","name":"MemoTopicFollow.reflect","line":58,"end_line":63,"hash":"8f8ef1f3a088f66698f261e25e9b76243355bb02ec50334a37c799e3348b99ba"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,89 @@
/*
Memo topic message behavior: compose, validate, and broadcast a Memo
topic message (0x6d0c).
A topic message transaction carries the Memo topic-message protocol prefix
followed by the topic name and message bytes. The combined topic name plus
message must not exceed MAX_TOPIC_MESSAGE_BYTES.
The wallet and an injected feed store are used so this module stays
testable and free of UI/network concerns; environmentally unsuitable I/O
lives behind those small adapter boundaries.
Constants
MEMO_TOPIC_MESSAGE_PREFIX : hex prefix for the Memo topic message action (0x6d0c)
MAX_TOPIC_MESSAGE_BYTES : maximum combined topic + message bytes (214)
*/
const MemoAction = require('./memo-action')
const { byteLength } = require('./utf8')
const MEMO_TOPIC_MESSAGE_PREFIX = '6d0c'
const MAX_TOPIC_MESSAGE_BYTES = 214
class MemoTopicPost extends MemoAction {
static config = {
prefix: MEMO_TOPIC_MESSAGE_PREFIX,
walletRequiredMsg: 'Memo topic post requires a wallet.',
lengthMessage: `Topic message is too long. Maximum is ${MAX_TOPIC_MESSAGE_BYTES} bytes.`,
emptyMessage: 'Topic message must not be empty.',
lengthCode: 'topic_post_length',
validationCode: 'topic_post_validation'
}
constructor (deps = {}) {
super(deps)
this.room = deps.room || ''
this.feed = deps.feed || null
}
// A topic message is over-length when the topic name plus the message
// exceed the combined byte budget.
isTooLong (message) {
return byteLength(this.room) + byteLength(message) > MAX_TOPIC_MESSAGE_BYTES
}
remainingBytes (message) {
return MAX_TOPIC_MESSAGE_BYTES - byteLength(this.room) - byteLength(message)
}
// Compose and broadcast a Memo topic message for the given message.
async post (message) {
const check = this.validate(message)
this._throwIfInvalid(check)
if (!this.wallet) {
throw new Error(this.walletRequiredMsg)
}
await this.wallet.getUtxos()
const payload = this.room + message
const txid = await this.wallet.sendOpReturn(payload, this.prefix)
this.reflect(txid, message)
return txid
}
// Record the new topic post on the injected feed when one is present.
reflect (txid, message) {
if (this.feed && typeof this.feed.addPost === 'function') {
this.feed.addPost({
txid,
address: this.wallet.walletInfo.cashAddress,
text: message,
room: this.room
})
}
}
}
MemoTopicPost.MEMO_TOPIC_MESSAGE_PREFIX = MEMO_TOPIC_MESSAGE_PREFIX
MemoTopicPost.MAX_TOPIC_MESSAGE_BYTES = MAX_TOPIC_MESSAGE_BYTES
module.exports = MemoTopicPost
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T18:18:11.190Z","module_hash":"c73b1395983accf085921554a84c8ef9ad8014ba8d8ddfa794e051ae4a487f04","functions":[{"id":"func/MemoTopicPost.constructor","name":"MemoTopicPost.constructor","line":34,"end_line":38,"hash":"34483e7579cbde3babf17cc43f64bc6535537b6391169a75cb51025029283289"},{"id":"func/MemoTopicPost.isTooLong","name":"MemoTopicPost.isTooLong","line":42,"end_line":44,"hash":"a8d16816cf0aeda1be759f796aee75e9cc5d89ccbcc288b5d2551917121be27a"},{"id":"func/MemoTopicPost.remainingBytes","name":"MemoTopicPost.remainingBytes","line":46,"end_line":48,"hash":"11a07f6539fa6b74feabfdd258f83ede4556213951aa85266a959871ff616df1"},{"id":"func/MemoTopicPost.post","name":"MemoTopicPost.post","line":51,"end_line":67,"hash":"dec3508e4969efd3edfefbc0752ca4608bac9521fbda62cf2ce5d7bf0a5696c7"},{"id":"func/MemoTopicPost.reflect","name":"MemoTopicPost.reflect","line":70,"end_line":79,"hash":"8a136fe98fa6752b2247b9bc0064b9860cfa34e518abaa33e50e95833120d529"}]}
// mutate4javascript-manifest-end
+28 -8
View File
@@ -15,6 +15,7 @@ class Profiles {
this.bios = new Map()
this.avatarUrls = new Map()
this.following = new Map()
this.topicFollowing = new Map()
}
setName (addr, name) {
@@ -49,21 +50,40 @@ class Profiles {
// Track whether the current wallet follows a given address.
setFollowState (selfAddr, targetAddr, isFollowing) {
if (!selfAddr || !targetAddr) return
if (!this.following.has(selfAddr)) {
this.following.set(selfAddr, new Map())
}
this.following.get(selfAddr).set(targetAddr, isFollowing)
this._setMapState(this.following, selfAddr, targetAddr, isFollowing)
}
getFollowState (selfAddr, targetAddr) {
if (!selfAddr || !targetAddr) return false
return this.following.get(selfAddr)?.get(targetAddr) || false
return this._getMapState(this.following, selfAddr, targetAddr)
}
// Track whether the current wallet follows a given topic.
setTopicFollowState (selfAddr, room, isFollowing) {
this._setMapState(this.topicFollowing, selfAddr, room, isFollowing)
}
getTopicFollowState (selfAddr, room) {
return this._getMapState(this.topicFollowing, selfAddr, room)
}
// Set a boolean value on a per-self-address nested map.
_setMapState (map, selfAddr, key, value) {
if (!selfAddr || !key) return
if (!map.has(selfAddr)) {
map.set(selfAddr, new Map())
}
map.get(selfAddr).set(key, value)
}
// Read a boolean value from a per-self-address nested map.
_getMapState (map, selfAddr, key) {
if (!selfAddr || !key) return false
return map.get(selfAddr)?.get(key) || false
}
}
module.exports = Profiles
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-27T17:52:37.432Z","module_hash":"f729d4125eea29c45c4a0d7bd691a5f590512922f2ba78c85756643638fb0859","functions":[{"id":"func/Profiles.constructor","name":"Profiles.constructor","line":13,"end_line":18,"hash":"574f0b3772f783646c283c6ddc4d5ff200ca1f9918ab1fa668205003f885d4d3"},{"id":"func/Profiles.setName","name":"Profiles.setName","line":20,"end_line":23,"hash":"5a36c6e237798608de0bedd8744b75000c0eec5c0a6a64c870a25bcdf20aed21"},{"id":"func/Profiles.getName","name":"Profiles.getName","line":25,"end_line":28,"hash":"2fcb9d84687ea0f3b24f3e34c0a72eda55a4e869b87e08a7f4551321a4166198"},{"id":"func/Profiles.setBio","name":"Profiles.setBio","line":30,"end_line":33,"hash":"3825665ead1ba9bb217694c45a17bb2e11c78ffa053e5e90111a4b9208183b56"},{"id":"func/Profiles.getBio","name":"Profiles.getBio","line":35,"end_line":38,"hash":"2e9708249db4a0e4fa642cbe52e6216144ec91283281c61dee2ff3cc3d8f1572"},{"id":"func/Profiles.setAvatarUrl","name":"Profiles.setAvatarUrl","line":40,"end_line":43,"hash":"e4ccb0164fee4ab6cc3a1290fb4bf85e4a79f2227ca72872b1f10bc616926fd9"},{"id":"func/Profiles.getAvatarUrl","name":"Profiles.getAvatarUrl","line":45,"end_line":48,"hash":"0aa58dc15fd519e553136d04d1954e18084f79a6cd9349dc89b1136cf0a22a2b"},{"id":"func/Profiles.setFollowState","name":"Profiles.setFollowState","line":51,"end_line":57,"hash":"aa527936b6ca92a502e93e8241803318700c528e05f68ff7fb6fac1d410c197c"},{"id":"func/Profiles.getFollowState","name":"Profiles.getFollowState","line":59,"end_line":62,"hash":"299a44adb289026bf3b89a21947fc23bf05f5a40f07abb7f1bbd311c0479f562"}]}
// {"version":1,"tested_at":"2026-08-28T18:21:58.102Z","module_hash":"1cec5125fb228d43d47e4ec171eb43c492f2bd773ca315748df78a16071e2802","functions":[{"id":"func/Profiles.constructor","name":"Profiles.constructor","line":13,"end_line":19,"hash":"247804207ec0220b6e6507f6beb6f86023a7c08b50737a52a0dbb99d262993d3"},{"id":"func/Profiles.setName","name":"Profiles.setName","line":21,"end_line":24,"hash":"5a36c6e237798608de0bedd8744b75000c0eec5c0a6a64c870a25bcdf20aed21"},{"id":"func/Profiles.getName","name":"Profiles.getName","line":26,"end_line":29,"hash":"2fcb9d84687ea0f3b24f3e34c0a72eda55a4e869b87e08a7f4551321a4166198"},{"id":"func/Profiles.setBio","name":"Profiles.setBio","line":31,"end_line":34,"hash":"3825665ead1ba9bb217694c45a17bb2e11c78ffa053e5e90111a4b9208183b56"},{"id":"func/Profiles.getBio","name":"Profiles.getBio","line":36,"end_line":39,"hash":"2e9708249db4a0e4fa642cbe52e6216144ec91283281c61dee2ff3cc3d8f1572"},{"id":"func/Profiles.setAvatarUrl","name":"Profiles.setAvatarUrl","line":41,"end_line":44,"hash":"e4ccb0164fee4ab6cc3a1290fb4bf85e4a79f2227ca72872b1f10bc616926fd9"},{"id":"func/Profiles.getAvatarUrl","name":"Profiles.getAvatarUrl","line":46,"end_line":49,"hash":"0aa58dc15fd519e553136d04d1954e18084f79a6cd9349dc89b1136cf0a22a2b"},{"id":"func/Profiles.setFollowState","name":"Profiles.setFollowState","line":52,"end_line":54,"hash":"805cafb46052f91bfa0a123842c25ff8ee0f7f4a7e661f51d289b0487738dbd4"},{"id":"func/Profiles.getFollowState","name":"Profiles.getFollowState","line":56,"end_line":58,"hash":"48fdf5a632825946c391146628cd6f51b4eb0169c7a6d6b89c795ff554e62b07"},{"id":"func/Profiles.setTopicFollowState","name":"Profiles.setTopicFollowState","line":61,"end_line":63,"hash":"54ab980e42018d64fbacee7d80347d530791b7ba21d8fac46f5ed85f33903f98"},{"id":"func/Profiles.getTopicFollowState","name":"Profiles.getTopicFollowState","line":65,"end_line":67,"hash":"90ce88799dda5477e20ce4de17404dace55abf1d2f2c1c87671e94fc2f270205"},{"id":"func/Profiles._setMapState","name":"Profiles._setMapState","line":70,"end_line":76,"hash":"13d2cad84be961b89482970f55f107d93fa1bdc10636dc64844c6a9263e88583"},{"id":"func/Profiles._getMapState","name":"Profiles._getMapState","line":79,"end_line":82,"hash":"c1c8966a797533614d35c7a13deb8f757572127ebcbde1d234c7d966346d89a8"}]}
// mutate4javascript-manifest-end
@@ -10,8 +10,12 @@ class TopicFeedPage {
constructor (deps = {}) {
this.memoDb = deps.memoDb || null
this.room = deps.room || null
this.myAddr = deps.myAddr || null
this.memoTopicFollow = deps.memoTopicFollow || null
this.posts = []
this.pagination = null
this.followState = false
this.followers = []
}
async load ({ limit = 100, offset = 0 } = {}) {
@@ -25,8 +29,53 @@ class TopicFeedPage {
const data = await this.memoDb.getTopicPosts(this.room, { limit, offset })
this.posts = data.posts || []
this.pagination = data.pagination || null
this.followState = await this._loadFollowState()
this.followers = await this._loadFollowers()
return { posts: this.posts, pagination: this.pagination }
return { posts: this.posts, pagination: this.pagination, followState: this.followState, followers: this.followers }
}
async _loadFollowState () {
if (this.myAddr) {
return this.memoDb.getTopicFollowState(this.room, this.myAddr)
}
return false
}
async _loadFollowers () {
return this.memoDb.getTopicFollowers(this.room)
}
canFollow () {
return Boolean(this.myAddr)
}
isFollowing () {
return this.followState === true
}
async follow () {
return this._setFollowState('follow', true)
}
async unfollow () {
return this._setFollowState('unfollow', false)
}
async _setFollowState (method, nextState) {
if (!this.memoTopicFollow) {
throw new Error('Topic feed page requires a memo topic follow handler.')
}
await this.memoTopicFollow[method](this.room)
this.followState = nextState
if (nextState) {
if (!this.followers.includes(this.myAddr)) {
this.followers = [...this.followers, this.myAddr]
}
} else {
this.followers = this.followers.filter((addr) => addr !== this.myAddr)
}
return { ok: true }
}
getPost (txid) {
@@ -41,5 +90,5 @@ TopicFeedPage.topicFeedPath = function (room) {
module.exports = TopicFeedPage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T15:35:01.465Z","module_hash":"d6433e58dfe8e7d0095074424340477c0abcfdcac7f611e2267ed04232936a1d","functions":[{"id":"func/TopicFeedPage.constructor","name":"TopicFeedPage.constructor","line":10,"end_line":15,"hash":"5d839555bdd88de97e47d68ef4dd2bad3a657ec4f9ce93991d332902bb4c371e"},{"id":"func/TopicFeedPage.load","name":"TopicFeedPage.load","line":17,"end_line":30,"hash":"ddf4ed21448879fa579225491125e343ebc129ef08445da811d98e79c6c29042"},{"id":"func/TopicFeedPage.getPost","name":"TopicFeedPage.getPost","line":32,"end_line":34,"hash":"1a6ae1a02b0f79b5b62a2b2324a5f1edbd743bec004fe73cfef7970bead56885"}]}
// {"version":1,"tested_at":"2026-08-28T18:24:48.504Z","module_hash":"236ec55785bb69ac64c4a610ef4a5f8a8b03fae7b717d79c14c7fd2e755775e6","functions":[{"id":"func/TopicFeedPage.constructor","name":"TopicFeedPage.constructor","line":10,"end_line":19,"hash":"e6ee69c9caa13fcc05c7739fc185767f906c907a3031cf2c02abaab9220daca5"},{"id":"func/TopicFeedPage.load","name":"TopicFeedPage.load","line":21,"end_line":36,"hash":"ef85636d06f2b1eb6afd19b276bd8b9728ee35122d4424b3faf468de4a0e23a4"},{"id":"func/TopicFeedPage._loadFollowState","name":"TopicFeedPage._loadFollowState","line":38,"end_line":43,"hash":"d1984bb94b4f2e7c98c39524fc2fd0080dc5cff10e775a17c68856c2e3a2c249"},{"id":"func/TopicFeedPage._loadFollowers","name":"TopicFeedPage._loadFollowers","line":45,"end_line":47,"hash":"8b19d3623b4a872f4abe879ceda7e4018edf60efff03d037fe795b6558222b37"},{"id":"func/TopicFeedPage.canFollow","name":"TopicFeedPage.canFollow","line":49,"end_line":51,"hash":"2f82c2096bf6f542b60cf20d82cd70f366652fcce3a38ff14e82164d5ee86cad"},{"id":"func/TopicFeedPage.isFollowing","name":"TopicFeedPage.isFollowing","line":53,"end_line":55,"hash":"9fc0470db7ffea2da96d2dbbdceff55562e1f7e84377fbb0e8b60d53cc723b40"},{"id":"func/TopicFeedPage.follow","name":"TopicFeedPage.follow","line":57,"end_line":59,"hash":"7674b789a9d3c48e0f7a6e553613bac99b2f38449fcdd17faa3c6d7cd2773bdd"},{"id":"func/TopicFeedPage.unfollow","name":"TopicFeedPage.unfollow","line":61,"end_line":63,"hash":"69d278b09da1f284be6adacb7264f9c06ac71c42f8e8f1baf9e34d5b24a891fb"},{"id":"func/TopicFeedPage._setFollowState","name":"TopicFeedPage._setFollowState","line":65,"end_line":79,"hash":"698f22cdfc2c088446c732cf01864d8859b299e066c199eb542bc965413f48b4"},{"id":"func/TopicFeedPage.getPost","name":"TopicFeedPage.getPost","line":81,"end_line":83,"hash":"1a6ae1a02b0f79b5b62a2b2324a5f1edbd743bec004fe73cfef7970bead56885"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,56 @@
/*
Topic Post Page behavior: compose and broadcast a Memo topic message, with
a byte counter that counts down from the combined topic limit.
This is the testable controller behind the topic message composer on the
React "Topic Feed" page. It wraps the Memo topic-post behavior
(src/services/memo-topic-post.js) and adds page-level concerns: holding the
current input, computing the remaining byte count, surfacing
validation/length errors, and refreshing the topic feed after a successful
post.
The memoTopicPost and navigate concerns are injected so this module stays
free of UI/network concerns; environmentally unsuitable I/O lives behind
those small adapter boundaries.
*/
const PageController = require('./page-controller')
const MemoTopicPost = require('./memo-topic-post')
class TopicPostPage extends PageController {
constructor (deps = {}) {
super(deps)
this.memoTopicPost = deps.memoTopicPost || null
this.postingTopic = false
this.validationCodes = ['topic_post_validation', 'topic_post_length']
}
// Bytes remaining for the message given the topic name.
remainingCount () {
if (!this.memoTopicPost) {
throw new Error('Topic post page requires a memo topic post handler.')
}
return this.memoTopicPost.remainingBytes(this.input)
}
// Set the in-flight flag.
_setBusy (value) {
this.postingTopic = value
}
// Run the memo topic post action for the current input.
async _perform (input) {
if (!this.memoTopicPost) {
throw new Error('Topic post page requires a memo topic post handler.')
}
return this.memoTopicPost.post(input)
}
}
TopicPostPage.MAX_TOPIC_MESSAGE_BYTES = MemoTopicPost.MAX_TOPIC_MESSAGE_BYTES
module.exports = TopicPostPage
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T18:20:31.541Z","module_hash":"34b7d1b6936611010fd664764742b2d395db9b9c23699dc7ae0e938230d74847","functions":[{"id":"func/TopicPostPage.constructor","name":"TopicPostPage.constructor","line":21,"end_line":26,"hash":"a93d04b51599074b2e2aa3c85dda465e77fec20b43c73c76c3e153e98bdc1366"},{"id":"func/TopicPostPage.remainingCount","name":"TopicPostPage.remainingCount","line":29,"end_line":34,"hash":"e289a1a02748206cfb0967677fcff1ab92f2ceede8c6727d9b203538298e92e2"},{"id":"func/TopicPostPage._setBusy","name":"TopicPostPage._setBusy","line":37,"end_line":39,"hash":"4cc342945de59ccc4284e845c45fbd4c78508638c30fda621892d5554da2c985"},{"id":"func/TopicPostPage._perform","name":"TopicPostPage._perform","line":42,"end_line":47,"hash":"5900159e55ddb681034e06db3ec42e2e00d80cf99148f2bc99e15c766794fd7a"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,79 @@
/*
Property tests for the Memo topic-post byte accounting.
The unit tests probe isTooLong and remainingBytes at a few fixed fixtures.
These properties pin down invariants that hold over broad random UTF-8
inputs (mixed byte widths):
- byte accounting: remainingBytes(msg) always equals the combined byte
budget minus the room and message byte lengths.
- consistency: isTooLong(msg) is true exactly when remainingBytes(msg)
is negative.
- monotonicity: appending bytes never flips a too-long message back to
being accepted.
*/
'use strict'
const test = require('node:test')
const { seededRandom, forAll } = require('./harness')
const MemoTopicPost = require('../../src/services/memo-topic-post')
const { byteLength } = require('../../src/services/utf8')
const rng = seededRandom(20260901)
const MAX = MemoTopicPost.MAX_TOPIC_MESSAGE_BYTES
// Characters with distinct UTF-8 byte widths: 1, 1, 1, 2, 3, and 4 bytes.
const CHARS = ['a', 'b', ' ', '\u00e9', '\u20ac', '\ud83d\ude00']
function textGen () {
const len = Math.floor(rng() * 140)
let out = ''
for (let i = 0; i < len; i++) {
out += CHARS[Math.floor(rng() * CHARS.length)]
}
return out
}
function postInputGen () {
return () => ({ room: textGen(), msg: textGen() })
}
test('remainingBytes equals the byte budget minus room and message bytes', async () => {
await forAll(
postInputGen(),
({ room, msg }) => {
const post = new MemoTopicPost({ room })
return post.remainingBytes(msg) === MAX - byteLength(room) - byteLength(msg)
},
{ label: 'remainingBytes byte accounting' }
)
})
test('isTooLong is true exactly when remainingBytes is negative', async () => {
await forAll(
postInputGen(),
({ room, msg }) => {
const post = new MemoTopicPost({ room })
return post.isTooLong(msg) === (post.remainingBytes(msg) < 0)
},
{ label: 'isTooLong / remainingBytes consistency' }
)
})
test('isTooLong is monotonic as message bytes are appended', async () => {
await forAll(
() => {
const { room, msg } = postInputGen()()
const extra = textGen()
return { room, msg, extra }
},
({ room, msg, extra }) => {
const post = new MemoTopicPost({ room })
// Appending bytes can only make a message longer, so a too-long
// message must remain too-long once more bytes are added.
return !(post.isTooLong(msg) && !post.isTooLong(msg + extra))
},
{ label: 'isTooLong monotonicity' }
)
})
@@ -64,7 +64,10 @@ test('TopicFeedPage.getPost finds any loaded post', async () => {
return posts
},
async (posts) => {
const memoDb = { async getTopicPosts () { return { posts, pagination: { total: posts.length } } } }
const memoDb = {
async getTopicPosts () { return { posts, pagination: { total: posts.length } } },
async getTopicFollowers () { return [] }
}
const page = new TopicFeedPage({ memoDb, room: 'bitcoin' })
await page.load()
@@ -0,0 +1,113 @@
/*
Unit tests for the Memo topic follow/unfollow behavior.
A topic follow or unfollow transaction carries the topic name as plain UTF-8
text with the Memo topic-follow (0x6d0d) or topic-unfollow (0x6d0e) prefix.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const MemoTopicFollow = require('../../src/services/memo-topic-follow')
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
function makeWallet (address = MY_ADDRESS) {
return {
walletInfo: { cashAddress: address },
broadcasts: [],
async getUtxos () {
return []
},
async sendOpReturn (msg, prefix) {
this.broadcasts.push({ msg, prefix })
return 'aa'.repeat(32)
}
}
}
function makeProfiles () {
const state = {}
return {
state,
setTopicFollowState: (selfAddr, room, isFollowing) => {
if (!state[selfAddr]) state[selfAddr] = {}
state[selfAddr][room] = isFollowing
},
getTopicFollowState: (selfAddr, room) => state[selfAddr]?.[room] || false
}
}
test('follow broadcasts with the Memo topic-follow prefix and topic name', async () => {
const wallet = makeWallet()
const memoTopicFollow = new MemoTopicFollow({ wallet })
await memoTopicFollow.follow('bitcoin')
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoTopicFollow.MEMO_TOPIC_FOLLOW_PREFIX)
assert.equal(wallet.broadcasts[0].msg, 'bitcoin')
})
test('unfollow broadcasts with the Memo topic-unfollow prefix and topic name', async () => {
const wallet = makeWallet()
const memoTopicFollow = new MemoTopicFollow({ wallet })
await memoTopicFollow.unfollow('bitcoin')
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoTopicFollow.MEMO_TOPIC_UNFOLLOW_PREFIX)
assert.equal(wallet.broadcasts[0].msg, 'bitcoin')
})
test('follow reflects the new state on the profile store', async () => {
const wallet = makeWallet()
const profiles = makeProfiles()
const memoTopicFollow = new MemoTopicFollow({ wallet, profiles })
await memoTopicFollow.follow('bitcoin')
assert.equal(profiles.getTopicFollowState(MY_ADDRESS, 'bitcoin'), true)
})
test('unfollow reflects the new state on the profile store', async () => {
const wallet = makeWallet()
const profiles = makeProfiles()
const memoTopicFollow = new MemoTopicFollow({ wallet, profiles })
await memoTopicFollow.unfollow('bitcoin')
assert.equal(profiles.getTopicFollowState(MY_ADDRESS, 'bitcoin'), false)
})
test('follow rejects an empty topic name', async () => {
const wallet = makeWallet()
const memoTopicFollow = new MemoTopicFollow({ wallet })
await assert.rejects(
() => memoTopicFollow.follow(''),
{ code: 'topic_follow_validation', message: /Topic name is required/ }
)
assert.equal(wallet.broadcasts.length, 0)
})
test('follow requires a wallet', async () => {
const memoTopicFollow = new MemoTopicFollow({})
await assert.rejects(
() => memoTopicFollow.follow('bitcoin'),
/requires a wallet/
)
})
test('follow surfaces a broadcast failure', async () => {
const wallet = makeWallet()
wallet.sendOpReturn = async () => { throw new Error('broadcast failed') }
const memoTopicFollow = new MemoTopicFollow({ wallet })
await assert.rejects(
() => memoTopicFollow.follow('bitcoin'),
/broadcast failed/
)
})
@@ -0,0 +1,124 @@
/*
Unit tests for the Memo topic message behavior.
A topic message combines the topic name and message text, then broadcasts
it with the Memo topic-message prefix (0x6d0c). The combined topic + message
must not exceed 214 bytes.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const MemoTopicPost = require('../../src/services/memo-topic-post')
function makeWallet (address = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') {
return {
walletInfo: { cashAddress: address },
broadcasts: [],
async getUtxos () {
return []
},
async sendOpReturn (msg, prefix) {
this.broadcasts.push({ msg, prefix })
return 'aa'.repeat(32)
}
}
}
test('post broadcasts topic name and message with the topic-message prefix', async () => {
const wallet = makeWallet()
const memoTopicPost = new MemoTopicPost({ wallet, room: 'bitcoin' })
await memoTopicPost.post('hello bitcoin')
assert.equal(wallet.broadcasts.length, 1)
assert.equal(wallet.broadcasts[0].prefix, MemoTopicPost.MEMO_TOPIC_MESSAGE_PREFIX)
assert.equal(wallet.broadcasts[0].msg, 'bitcoinhello bitcoin')
})
test('post reflects the new topic post on the injected feed', async () => {
const wallet = makeWallet()
const added = []
const feed = {
addPost (post) {
added.push(post)
}
}
const memoTopicPost = new MemoTopicPost({ wallet, room: 'cash', feed })
await memoTopicPost.post('hello cash')
assert.equal(added.length, 1)
assert.equal(added[0].text, 'hello cash')
assert.equal(added[0].room, 'cash')
assert.equal(added[0].address, wallet.walletInfo.cashAddress)
})
test('post rejects an empty message', async () => {
const wallet = makeWallet()
const memoTopicPost = new MemoTopicPost({ wallet, room: 'bitcoin' })
await assert.rejects(
() => memoTopicPost.post(''),
{ code: 'topic_post_validation', message: /must not be empty/ }
)
assert.equal(wallet.broadcasts.length, 0)
})
test('post rejects a message that exceeds the combined byte limit', async () => {
const wallet = makeWallet()
const memoTopicPost = new MemoTopicPost({ wallet, room: 'bitcoin' })
// "bitcoin" is 7 bytes; 214 - 7 = 207. Use 208 ASCII 'a' characters.
const message = 'a'.repeat(208)
await assert.rejects(
() => memoTopicPost.post(message),
{ code: 'topic_post_length', message: /too long/ }
)
assert.equal(wallet.broadcasts.length, 0)
})
test('post accepts a message at the combined byte limit', async () => {
const wallet = makeWallet()
const memoTopicPost = new MemoTopicPost({ wallet, room: 'bitcoin' })
const message = 'a'.repeat(207)
await memoTopicPost.post(message)
assert.equal(wallet.broadcasts.length, 1)
})
test('remainingBytes accounts for the topic name', () => {
const memoTopicPost = new MemoTopicPost({ room: 'bitcoin' })
assert.equal(memoTopicPost.remainingBytes(''), 207)
assert.equal(memoTopicPost.remainingBytes('hello'), 202)
})
test('remainingBytes counts UTF-8 bytes', () => {
const memoTopicPost = new MemoTopicPost({ room: 'bitcoin' })
// 'é' is 2 UTF-8 bytes.
assert.equal(memoTopicPost.remainingBytes('é'), 205)
})
test('post requires a wallet', async () => {
const memoTopicPost = new MemoTopicPost({ room: 'bitcoin' })
await assert.rejects(
() => memoTopicPost.post('hello'),
/requires a wallet/
)
})
test('post surfaces a broadcast failure', async () => {
const wallet = makeWallet()
wallet.sendOpReturn = async () => { throw new Error('broadcast failed') }
const memoTopicPost = new MemoTopicPost({ wallet, room: 'bitcoin' })
await assert.rejects(
() => memoTopicPost.post('hello'),
/broadcast failed/
)
})
@@ -134,6 +134,85 @@ test('follow state storage is independent per self address', () => {
assert.equal(profiles.getFollowState(otherSelfAddr, targetAddr), false)
})
test('setTopicFollowState stores and getTopicFollowState retrieves topic follow state', () => {
const profiles = new Profiles()
const selfAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const room = 'bitcoin'
profiles.setTopicFollowState(selfAddr, room, true)
assert.equal(profiles.getTopicFollowState(selfAddr, room), true)
})
test('getTopicFollowState returns false for an unknown topic relationship', () => {
const profiles = new Profiles()
const selfAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
assert.equal(profiles.getTopicFollowState(selfAddr, 'bitcoin'), false)
})
test('setTopicFollowState with no self address does nothing', () => {
const profiles = new Profiles()
profiles.setTopicFollowState('', 'bitcoin', true)
assert.equal(profiles.getTopicFollowState('', 'bitcoin'), false)
})
test('setTopicFollowState with no room does nothing', () => {
const profiles = new Profiles()
const selfAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
profiles.setTopicFollowState(selfAddr, '', true)
assert.equal(profiles.getTopicFollowState(selfAddr, ''), false)
})
test('_setMapState stores nothing when either input is missing', () => {
const profiles = new Profiles()
// Missing self address, present room.
profiles.setTopicFollowState('', 'bitcoin', true)
// Present self address, missing room.
profiles.setTopicFollowState('bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d', '', true)
assert.equal(profiles.topicFollowing.size, 0)
})
test('_getMapState ignores stored state when either input is missing', () => {
const profiles = new Profiles()
// Manually store under a missing room to prove the read guard alone reports
// not-following instead of leaking the stored value.
profiles.topicFollowing.set('bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d', new Map([['', true]]))
assert.equal(profiles.getTopicFollowState('bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d', ''), false)
})
test('topic follow state storage is independent per self address', () => {
const profiles = new Profiles()
const selfAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const otherSelfAddr = 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a'
profiles.setTopicFollowState(selfAddr, 'bitcoin', true)
profiles.setTopicFollowState(otherSelfAddr, 'bitcoin', false)
assert.equal(profiles.getTopicFollowState(selfAddr, 'bitcoin'), true)
assert.equal(profiles.getTopicFollowState(otherSelfAddr, 'bitcoin'), false)
})
test('topic follow state storage is independent of address follow state', () => {
const profiles = new Profiles()
const selfAddr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
const targetAddr = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
profiles.setFollowState(selfAddr, targetAddr, true)
profiles.setTopicFollowState(selfAddr, 'bitcoin', true)
assert.equal(profiles.getFollowState(selfAddr, targetAddr), true)
assert.equal(profiles.getTopicFollowState(selfAddr, 'bitcoin'), true)
})
test('avatar URL storage is independent of name and bio storage', () => {
const profiles = new Profiles()
const addr = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
@@ -8,10 +8,32 @@ const test = require('node:test')
const assert = require('node:assert/strict')
const TopicFeedPage = require('../../src/services/topic-feed-page')
function makeMemoDb (posts, pagination) {
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
function makeMemoDb (posts, pagination, { following = false, followers = [] } = {}) {
return {
async getTopicPosts (room, { limit, offset }) {
return { posts, pagination }
},
async getTopicFollowState (room, addr) {
return following
},
async getTopicFollowers (room) {
return followers
}
}
}
function makeMemoTopicFollow () {
return {
broadcasts: [],
async follow (room) {
this.broadcasts.push({ action: 'follow', room })
return 'aa'.repeat(32)
},
async unfollow (room) {
this.broadcasts.push({ action: 'unfollow', room })
return 'bb'.repeat(32)
}
}
}
@@ -34,6 +56,12 @@ test('load forwards limit and offset to the memo db client', async () => {
async getTopicPosts (room, params) {
calls.push({ room, params })
return { posts: [], pagination: {} }
},
async getTopicFollowState () {
return false
},
async getTopicFollowers () {
return []
}
}
const page = new TopicFeedPage({ memoDb, room: 'bitcoin' })
@@ -49,6 +77,12 @@ test('load defaults limit and offset', async () => {
async getTopicPosts (room, params) {
calls.push(params)
return { posts: [], pagination: {} }
},
async getTopicFollowState () {
return false
},
async getTopicFollowers () {
return []
}
}
const page = new TopicFeedPage({ memoDb, room: 'bitcoin' })
@@ -85,6 +119,13 @@ test('load throws when no room is provided', async () => {
)
})
test('starts not following and with no followers', () => {
const page = new TopicFeedPage({ room: 'bitcoin' })
assert.equal(page.followState, false)
assert.deepEqual(page.followers, [])
})
test('getPost returns a loaded post by txid', async () => {
const posts = [{ txid: 'a'.repeat(64), text: 'hello bitcoin' }]
const page = new TopicFeedPage({ memoDb: makeMemoDb(posts, {}), room: 'bitcoin' })
@@ -102,6 +143,86 @@ test('getPost returns null for an unknown txid', async () => {
assert.equal(page.getPost('c'.repeat(64)), null)
})
test('load fetches follow state when myAddr is provided', async () => {
const page = new TopicFeedPage({
memoDb: makeMemoDb([], {}, { following: true }),
room: 'bitcoin',
myAddr: MY_ADDRESS
})
const result = await page.load()
assert.equal(result.followState, true)
assert.equal(page.isFollowing(), true)
})
test('load returns following=false when no myAddr is provided', async () => {
const page = new TopicFeedPage({ memoDb: makeMemoDb([], {}), room: 'bitcoin' })
const result = await page.load()
assert.equal(result.followState, false)
})
test('load fetches followers list', async () => {
const followers = ['bitcoincash:a', 'bitcoincash:b']
const page = new TopicFeedPage({
memoDb: makeMemoDb([], {}, { followers }),
room: 'bitcoin'
})
const result = await page.load()
assert.deepEqual(result.followers, followers)
})
test('follow broadcasts and updates local state', async () => {
const memoTopicFollow = makeMemoTopicFollow()
const page = new TopicFeedPage({
memoDb: makeMemoDb([], {}),
room: 'bitcoin',
myAddr: MY_ADDRESS,
memoTopicFollow
})
const result = await page.follow()
assert.equal(result.ok, true)
assert.equal(page.isFollowing(), true)
assert.deepEqual(memoTopicFollow.broadcasts, [{ action: 'follow', room: 'bitcoin' }])
assert.ok(page.followers.includes(MY_ADDRESS))
})
test('unfollow broadcasts and updates local state', async () => {
const memoTopicFollow = makeMemoTopicFollow()
const page = new TopicFeedPage({
memoDb: makeMemoDb([], {}),
room: 'bitcoin',
myAddr: MY_ADDRESS,
memoTopicFollow
})
const result = await page.unfollow()
assert.equal(result.ok, true)
assert.equal(page.isFollowing(), false)
assert.deepEqual(memoTopicFollow.broadcasts, [{ action: 'unfollow', room: 'bitcoin' }])
assert.ok(!page.followers.includes(MY_ADDRESS))
})
test('follow requires a memo topic follow handler', async () => {
const page = new TopicFeedPage({
memoDb: makeMemoDb([], {}),
room: 'bitcoin',
myAddr: MY_ADDRESS
})
await assert.rejects(
() => page.follow(),
/requires a memo topic follow handler/
)
})
test('exposes the topic feed path for a room', () => {
assert.equal(TopicFeedPage.topicFeedPath('bitcoin'), '/topics/bitcoin')
})
@@ -0,0 +1,103 @@
/*
Unit tests for the Topic Post Page controller.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const TopicPostPage = require('../../src/services/topic-post-page')
function makeMemoTopicPost (room = 'bitcoin') {
const maxBytes = 214
return {
room,
posts: [],
async post (message) {
if (typeof message !== 'string' || message.trim().length === 0) {
const err = new Error('Topic message must not be empty.')
err.code = 'topic_post_validation'
throw err
}
if (room.length + message.length > maxBytes) {
const err = new Error(`Topic message is too long. Maximum is ${maxBytes} bytes.`)
err.code = 'topic_post_length'
throw err
}
this.posts.push(message)
return 'aa'.repeat(32)
},
remainingBytes (message) {
return maxBytes - room.length - (message ? message.length : 0)
}
}
}
test('submit posts the current input', async () => {
const memoTopicPost = makeMemoTopicPost()
const page = new TopicPostPage({ memoTopicPost })
page.setInput('hello bitcoin')
const result = await page.submit()
assert.equal(result.ok, true)
assert.deepEqual(memoTopicPost.posts, ['hello bitcoin'])
})
test('remainingCount delegates to the memo topic post handler', () => {
const memoTopicPost = makeMemoTopicPost()
const page = new TopicPostPage({ memoTopicPost })
page.setInput('hello')
assert.equal(page.remainingCount(), 202)
})
test('submit returns a validation error for empty input', async () => {
const memoTopicPost = makeMemoTopicPost()
const page = new TopicPostPage({ memoTopicPost })
page.setInput('')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'topic_post_validation')
})
test('submit returns a length error for over-long input', async () => {
const memoTopicPost = makeMemoTopicPost()
const page = new TopicPostPage({ memoTopicPost })
page.setInput('a'.repeat(300))
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'topic_post_length')
})
test('submit requires a memo topic post handler', async () => {
const page = new TopicPostPage({})
page.setInput('hello')
const result = await page.submit()
assert.equal(result.ok, false)
assert.equal(result.error, 'broadcast')
})
test('remainingCount requires a memo topic post handler', () => {
const page = new TopicPostPage({})
assert.throws(
() => page.remainingCount(),
/requires a memo topic post handler/
)
})
test('exposes the combined byte limit', () => {
assert.equal(TopicPostPage.MAX_TOPIC_MESSAGE_BYTES, 214)
})
test('starts not busy', () => {
const page = new TopicPostPage({})
assert.equal(page.postingTopic, false)
})
+82
View File
@@ -20,6 +20,8 @@ import ListFollowing from '../../src/use-cases/list-following.js'
import ListFollowers from '../../src/use-cases/list-followers.js'
import ListTopics from '../../src/use-cases/list-topics.js'
import ListTopicPosts from '../../src/use-cases/list-topic-posts.js'
import TopicFollowState from '../../src/use-cases/topic-follow-state.js'
import ListTopicFollowers from '../../src/use-cases/list-topic-followers.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const tmpDir = path.resolve(__dirname, '..', '..', 'tmp', 'acceptance')
@@ -96,6 +98,8 @@ async function createWorld () {
const listFollowers = new ListFollowers({ adapters })
const listTopics = new ListTopics({ adapters })
const listTopicPosts = new ListTopicPosts({ adapters })
const topicFollowState = new TopicFollowState({ adapters })
const listTopicFollowers = new ListTopicFollowers({ adapters })
let lastResponse = null
@@ -109,6 +113,8 @@ async function createWorld () {
listFollowers,
listTopics,
listTopicPosts,
topicFollowState,
listTopicFollowers,
postHeightsIteratorCounter,
addrPostHeightsIteratorCounter,
postChildrenIteratorCounter,
@@ -153,6 +159,11 @@ async function loadFixture (world, name) {
return
}
if (name === 'topic-follows') {
await loadTopicFollows(world)
return
}
if (name !== 'three-top-level-posts-and-one-reply') {
throw new Error(`Unknown fixture: ${name}`)
}
@@ -364,6 +375,19 @@ async function loadTopicsWithPosts (world) {
}
}
async function loadTopicFollows (world) {
const entries = [
{ key: 'bitcoin:addr-a', room: 'bitcoin', addr: 'addr-a', type: 'follow', unfollow: false },
{ key: 'bitcoin:addr-b', room: 'bitcoin', addr: 'addr-b', type: 'follow', unfollow: false },
{ key: 'bitcoin:addr-c', room: 'bitcoin', addr: 'addr-c', type: 'follow', unfollow: true },
{ key: 'cash:addr-a', room: 'cash', addr: 'addr-a', type: 'follow', unfollow: false }
]
for (const entry of entries) {
await world.adapters.level.roomsDb.put(entry.key, entry)
}
}
const handlers = [
{
name: 'db instance with posts and postHeights stores',
@@ -780,6 +804,64 @@ const handlers = [
throw new Error(`Expected no posts, got ${Array.isArray(posts) ? posts.length : 'non-array'}`)
}
}
},
{
name: 'db instance with rooms store',
pattern: /^a psf-memo-db instance with a rooms store$/,
async run () {
// World is already created with the rooms store.
}
},
{
name: 'load fixture into rooms store',
pattern: /^the fixture "(.+)" is loaded into the rooms store$/,
async run (m, example, world) {
await loadFixture(world, m[1])
}
},
{
name: 'request topic follow state',
pattern: /^the client requests the topic follow state for room (<[A-Za-z0-9_]+>) and address (<[A-Za-z0-9_]+>)$/,
async run (m, example, world) {
const room = resolveParam(m[1], example)
const addr = resolveParam(m[2], example)
const resp = await world.topicFollowState.execute({ room, addr })
world.setLastResponse(resp)
}
},
{
name: 'topic follow state reports following',
pattern: /^the topic follow state reports following (<following>)$/,
run (m, example, world) {
const expected = resolveParam(m[1], example) === 'true'
const actual = world.getLastResponse().following
if (actual !== expected) {
throw new Error(`Expected following ${expected}, got ${actual}`)
}
}
},
{
name: 'request topic followers list',
pattern: /^the client requests the topic followers list for room (<[A-Za-z0-9_]+>)$/,
async run (m, example, world) {
const room = resolveParam(m[1], example)
const resp = await world.listTopicFollowers.execute({ room })
world.setLastResponse(resp)
}
},
{
name: 'topic followers list contains addresses',
pattern: /^the topic followers list contains the addresses (<expected>)$/,
run (m, example, world) {
const raw = resolveParam(m[1], example).trim()
const expected = raw.length === 0 ? [] : raw.split(',').map((s) => s.trim())
const actual = world.getLastResponse().followers
const expectedSet = new Set(expected)
const actualSet = new Set(actual)
if (expectedSet.size !== actualSet.size || !expectedSet.isSubsetOf(actualSet)) {
throw new Error(`Expected topic followers ${expected.join(',')}, got ${actual.join(',')}`)
}
}
}
]
@@ -1,3 +1,7 @@
# acceptance-mutation-manifest-begin
# {"version":1,"tested_at":"2026-08-28T20:09:23.829689449Z","feature_name":"Topic Follow Read","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-db/specs/topic-follow-read.feature","background_hash":"bf41600ace5aaae19b01881936ae5ec595b53abbb9829b5f7927f43a830ded38","implementation_hash":"unknown","scenarios":[]}
# acceptance-mutation-manifest-end
# Scenarios: Topic Follow Read - 1, Topic Follow Read - 2
#
# The indexer stores topic follows in the rooms store keyed `${room}:${addr}`
+36 -1
View File
@@ -24,8 +24,11 @@ class TopicQuery {
this.listTopics = this.listTopics.bind(this)
this.getTopicPostTxids = this.getTopicPostTxids.bind(this)
this.isFollowingRoom = this.isFollowingRoom.bind(this)
this.listRoomFollowers = this.listRoomFollowers.bind(this)
this.roomFromKey = this.roomFromKey.bind(this)
this.txidFromKey = this.txidFromKey.bind(this)
this.followAddrFromValue = this.followAddrFromValue.bind(this)
}
roomFromKey (key, value) {
@@ -75,10 +78,42 @@ class TopicQuery {
return { txids, total }
}
// Return true when addr has an active follow record for room.
async isFollowingRoom (addr, room) {
const key = `${room}:${addr}`
try {
const record = await this.roomsDb.get(key)
return record?.type === 'follow' && record?.unfollow !== true
} catch (err) {
if (err.notFound) return false
throw err
}
}
// Return the cash addresses that currently follow the room.
async listRoomFollowers (room) {
const start = `${room}:`
const end = `${room}:\uffff`
const followers = []
for await (const [key, value] of this.roomsDb.iterator({ gte: start, lte: end })) {
if (value?.type !== 'follow') continue
if (value?.unfollow === true) continue
const addr = this.followAddrFromValue(value, key)
if (addr) followers.push(addr)
}
return followers
}
followAddrFromValue (value, key) {
if (value && typeof value.addr === 'string') return value.addr
const parts = String(key).split(':')
return parts.length > 1 ? parts[parts.length - 1] : null
}
}
export default TopicQuery
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T14:46:39.922Z","module_hash":"515aa0e8cfdc7d596b270d5c4b82e2f33efa7f074ea406b6850007402d69f2e2","functions":[{"id":"func/TopicQuery.constructor","name":"TopicQuery.constructor","line":14,"end_line":29,"hash":"1c1e10e3ee613b0cd141a941849ce28a99eac688142629c7781ded6e5e15c938"},{"id":"func/TopicQuery.roomFromKey","name":"TopicQuery.roomFromKey","line":31,"end_line":34,"hash":"4175916ac2bb8f9f102c70750f336ed980db2a30a1f8f35645534303408aaece"},{"id":"func/TopicQuery.txidFromKey","name":"TopicQuery.txidFromKey","line":36,"end_line":39,"hash":"4f5f5c456f1e74a60acff54d1d5e2d982be53886c2a77c6eff3293133efa9c87"},{"id":"func/TopicQuery.listTopics","name":"TopicQuery.listTopics","line":41,"end_line":57,"hash":"9c524ef62e22d7a434768dc431c360f9c81b0c2a321b162a614f7f7e1e011e08"},{"id":"func/TopicQuery.getTopicPostTxids","name":"TopicQuery.getTopicPostTxids","line":59,"end_line":77,"hash":"4e0fccf874560249152d04d13879c24fbae0271bf7835716789dfafcff7dbd04"}]}
// {"version":1,"tested_at":"2026-08-28T19:16:59.454Z","module_hash":"7b5597f76aed02abe591e652087cb1c560dd17a1154a62299f50f60303aa6e87","functions":[{"id":"func/TopicQuery.constructor","name":"TopicQuery.constructor","line":14,"end_line":32,"hash":"08f209a40b4ffc2962e5f399e78936005f3286ec37123a043850b12e0c20833b"},{"id":"func/TopicQuery.roomFromKey","name":"TopicQuery.roomFromKey","line":34,"end_line":37,"hash":"4175916ac2bb8f9f102c70750f336ed980db2a30a1f8f35645534303408aaece"},{"id":"func/TopicQuery.txidFromKey","name":"TopicQuery.txidFromKey","line":39,"end_line":42,"hash":"4f5f5c456f1e74a60acff54d1d5e2d982be53886c2a77c6eff3293133efa9c87"},{"id":"func/TopicQuery.listTopics","name":"TopicQuery.listTopics","line":44,"end_line":60,"hash":"9c524ef62e22d7a434768dc431c360f9c81b0c2a321b162a614f7f7e1e011e08"},{"id":"func/TopicQuery.getTopicPostTxids","name":"TopicQuery.getTopicPostTxids","line":62,"end_line":80,"hash":"4e0fccf874560249152d04d13879c24fbae0271bf7835716789dfafcff7dbd04"},{"id":"func/TopicQuery.isFollowingRoom","name":"TopicQuery.isFollowingRoom","line":83,"end_line":92,"hash":"a0cf48cc6f4842adf65830b41d536cd6a078c7adf6b837e57259491157e2e97d"},{"id":"func/TopicQuery.listRoomFollowers","name":"TopicQuery.listRoomFollowers","line":95,"end_line":106,"hash":"351d71881e8b0953366056f223a6f06326e8ecd946277abc372ae5771c0b0f08"},{"id":"func/TopicQuery.followAddrFromValue","name":"TopicQuery.followAddrFromValue","line":108,"end_line":112,"hash":"2ce68c35f539e1fd7694774059b4b2afd096014c99a87c7538f3cbb70e26c3c7"}]}
// mutate4javascript-manifest-end
@@ -17,6 +17,8 @@ class TopicsRESTControllerLib {
this.getTopics = this.getTopics.bind(this)
this.getTopicPosts = this.getTopicPosts.bind(this)
this.getTopicFollowState = this.getTopicFollowState.bind(this)
this.getTopicFollowers = this.getTopicFollowers.bind(this)
this.handleError = this.handleError.bind(this)
}
@@ -86,10 +88,63 @@ class TopicsRESTControllerLib {
this.handleError(ctx, err)
}
}
/**
* @api {get} /topics/:room/follow/state Check topic follow state
* @apiPermission public
* @apiName GetTopicFollowState
* @apiGroup REST Topics
*
* @apiDescription Returns whether an address follows a topic.
*
* @apiParam {String} room Topic name
* @apiQuery {String} addr Cash address to check
*
* @apiExample Example usage:
* curl -X GET "localhost:5021/topics/bitcoin/follow/state?addr=bitcoincash:q..."
*
* @apiSuccess {String} room Topic name
* @apiSuccess {String} addr Checked cash address
* @apiSuccess {Boolean} following True when an active topic follow exists
*/
async getTopicFollowState (ctx) {
try {
const { room } = ctx.params
const { addr } = ctx.query
ctx.body = await this.useCases.topicFollowState.execute({ room, addr })
} catch (err) {
this.handleError(ctx, err)
}
}
/**
* @api {get} /topics/:room/followers List topic followers
* @apiPermission public
* @apiName GetTopicFollowers
* @apiGroup REST Topics
*
* @apiDescription Returns the addresses that currently follow a topic.
*
* @apiParam {String} room Topic name
*
* @apiExample Example usage:
* curl -X GET "localhost:5021/topics/bitcoin/followers"
*
* @apiSuccess {String} room Topic name
* @apiSuccess {String[]} followers Array of follower cash addresses
*/
async getTopicFollowers (ctx) {
try {
const { room } = ctx.params
ctx.body = await this.useCases.listTopicFollowers.execute({ room })
} catch (err) {
this.handleError(ctx, err)
}
}
}
export default TopicsRESTControllerLib
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T15:26:54.488Z","module_hash":"64ac0a8aa7c2e0b575a89161ceb19c59e6618448eaf767ef5bbccc3ce174afaa","functions":[{"id":"func/TopicsRESTControllerLib.constructor","name":"TopicsRESTControllerLib.constructor","line":8,"end_line":21,"hash":"63d2092a2bd7946dd11bdf0c4ff102e31c47a0ed6ad56a0652f26e1760d525f9"},{"id":"func/TopicsRESTControllerLib.handleError","name":"TopicsRESTControllerLib.handleError","line":23,"end_line":30,"hash":"b9ba0c7b9752ac2fda3cf058a983a8b6708715e2e4dc882903ab5ef369956e03"},{"id":"func/TopicsRESTControllerLib.getTopics","name":"TopicsRESTControllerLib.getTopics","line":47,"end_line":53,"hash":"b89a696bcd1d601ef86754974dbf181baf2ece7d592674ba4fd87887725d6fa8"},{"id":"func/TopicsRESTControllerLib.getTopicPosts","name":"TopicsRESTControllerLib.getTopicPosts","line":80,"end_line":88,"hash":"8ad5f9b1749968c3fbfea95683bb388a5d99eda0b8fe7a4dce3447d6d47e6dba"}]}
// {"version":1,"tested_at":"2026-08-28T19:53:31.127Z","module_hash":"4c3f3e9152899ed272cb583e558c4522c0f54c37a0ae728f3ec6bc40248a3fdc","functions":[{"id":"func/TopicsRESTControllerLib.constructor","name":"TopicsRESTControllerLib.constructor","line":8,"end_line":23,"hash":"bacd230f3777dda39ec134a69a9a7c1801cb3cd09c0cc86659df324540053028"},{"id":"func/TopicsRESTControllerLib.handleError","name":"TopicsRESTControllerLib.handleError","line":25,"end_line":32,"hash":"b9ba0c7b9752ac2fda3cf058a983a8b6708715e2e4dc882903ab5ef369956e03"},{"id":"func/TopicsRESTControllerLib.getTopics","name":"TopicsRESTControllerLib.getTopics","line":49,"end_line":55,"hash":"b89a696bcd1d601ef86754974dbf181baf2ece7d592674ba4fd87887725d6fa8"},{"id":"func/TopicsRESTControllerLib.getTopicPosts","name":"TopicsRESTControllerLib.getTopicPosts","line":82,"end_line":90,"hash":"8ad5f9b1749968c3fbfea95683bb388a5d99eda0b8fe7a4dce3447d6d47e6dba"},{"id":"func/TopicsRESTControllerLib.getTopicFollowState","name":"TopicsRESTControllerLib.getTopicFollowState","line":110,"end_line":118,"hash":"6d72fecc2bf46276cd00d39cc1c19ed6647beed1f2f3ba187a2c085ed4308611"},{"id":"func/TopicsRESTControllerLib.getTopicFollowers","name":"TopicsRESTControllerLib.getTopicFollowers","line":136,"end_line":143,"hash":"2bcd22520d962c008a3255f5cf6cf536fdeb738d036d3c6b6d61d5d5cf040615"}]}
// mutate4javascript-manifest-end
@@ -26,6 +26,8 @@ class TopicsRouter {
attach (app) {
this.router.get('/', this.topicsRESTController.getTopics)
this.router.get('/:room/posts', this.topicsRESTController.getTopicPosts)
this.router.get('/:room/follow/state', this.topicsRESTController.getTopicFollowState)
this.router.get('/:room/followers', this.topicsRESTController.getTopicFollowers)
app.use(this.router.routes())
app.use(this.router.allowedMethods())
}
+16
View File
@@ -11,6 +11,8 @@ import ListFollowing from './list-following.js'
import ListFollowers from './list-followers.js'
import ListTopics from './list-topics.js'
import ListTopicPosts from './list-topic-posts.js'
import TopicFollowState from './topic-follow-state.js'
import ListTopicFollowers from './list-topic-followers.js'
class UseCases {
constructor (localConfig = {}) {
@@ -31,6 +33,8 @@ class UseCases {
this.listFollowers = null
this.listTopics = null
this.listTopicPosts = null
this.topicFollowState = null
this.listTopicFollowers = null
}
async start () {
@@ -70,6 +74,14 @@ class UseCases {
adapters: this.adapters
})
this.topicFollowState = new TopicFollowState({
adapters: this.adapters
})
this.listTopicFollowers = new ListTopicFollowers({
adapters: this.adapters
})
console.log('Use cases initialized.')
return true
@@ -77,3 +89,7 @@ class UseCases {
}
export default UseCases
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T20:07:25.668Z","module_hash":"4056d0f29e6828f95ab07f34186b3cef9d93f244c3d6914aa52463abdc224f32","functions":[{"id":"func/UseCases.constructor","name":"UseCases.constructor","line":18,"end_line":38,"hash":"bf14b8bc15f8a9ad966bb4d2a2de92072be3ec695af1aef5604495f5cf681e34"},{"id":"func/UseCases.start","name":"UseCases.start","line":40,"end_line":88,"hash":"f6fbf584c0e6786294a352fdae4e7a01fef1d24d31ca5202a33f5ed5e1772020"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,30 @@
/*
Use case: list the addresses that currently follow a Memo topic.
Returns { room, followers: string[] }.
*/
import { ListUseCase } from './lib/use-case.js'
class ListTopicFollowers extends ListUseCase {
constructor (localConfig = {}) {
super(localConfig, { useCaseName: 'ListTopicFollowers', adapterName: 'topicQuery' })
}
async execute (inObj = {}) {
const { room } = inObj
if (!room || typeof room !== 'string') {
throw new Error('room is required')
}
const followers = await this.adapters.topicQuery.listRoomFollowers(room)
return { room, followers }
}
}
export default ListTopicFollowers
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T20:05:30.757Z","module_hash":"c2df7f5656fb718fe66f800585bef77d926c25b780925c4bafcb05a4888c7bb3","functions":[{"id":"func/ListTopicFollowers.constructor","name":"ListTopicFollowers.constructor","line":10,"end_line":12,"hash":"032be6dff5472c86a00dbfece93708dcfb41f0d5fa3d5aa9a08c3e97ca5a922e"},{"id":"func/ListTopicFollowers.execute","name":"ListTopicFollowers.execute","line":14,"end_line":23,"hash":"744248c669d8dbf5ced565d5a3328c9273ebac5f5db44bb5e947e7d26d4177a6"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,33 @@
/*
Use case: report whether an address follows a Memo topic.
Returns { room, addr, following: boolean }.
*/
import { ListUseCase } from './lib/use-case.js'
class TopicFollowState extends ListUseCase {
constructor (localConfig = {}) {
super(localConfig, { useCaseName: 'TopicFollowState', adapterName: 'topicQuery' })
}
async execute (inObj = {}) {
const { room, addr } = inObj
if (!room || typeof room !== 'string') {
throw new Error('room is required')
}
if (!addr || typeof addr !== 'string') {
throw new Error('addr is required')
}
const following = await this.adapters.topicQuery.isFollowingRoom(addr, room)
return { room, addr, following }
}
}
export default TopicFollowState
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-08-28T19:53:30.917Z","module_hash":"48b427de8b141c64275851761259c085142d7137817712cebaa6d42eea27a366","functions":[{"id":"func/TopicFollowState.constructor","name":"TopicFollowState.constructor","line":10,"end_line":12,"hash":"028e260fea5ad97395eaa65be5eaea9aa3134ae1a37c530b96b66df6e864fb0b"},{"id":"func/TopicFollowState.execute","name":"TopicFollowState.execute","line":14,"end_line":26,"hash":"7474d322e757cdb2ab2a03c65f17456c33196811171b478dd813fbc020d8e4bf"}]}
// mutate4javascript-manifest-end
@@ -0,0 +1,156 @@
/*
Property tests for the TopicQuery topic-follow read side.
The unit tests probe isFollowingRoom and listRoomFollowers at a few fixed
fixtures. These properties pin down invariants that hold over broad random
follow-record sets:
- listRoomFollowers conservation: for every room the returned set equals
exactly the active (non-unfollowed) follow addresses for that room,
regardless of surrounding post/follow records.
- isFollowingRoom round trip: an address is reported following a room
exactly when an active follow record for that room:addr exists.
- followAddrFromValue recovery: the follower address is recovered from a
`${room}:${addr}` key.
*/
import test from 'node:test'
import { seededRandom, forAll, intGen } from './harness.js'
import TopicQuery from '../../src/adapters/topic-query.js'
const rng = seededRandom(20260831)
// In-memory rooms store mirroring the LevelDB contract TopicQuery relies on:
// both an iterator over key/value entries (listRoomFollowers, listTopics) and
// a point get that throws notFound (isFollowingRoom).
function makeRoomsDb (entries) {
const store = new Map(entries.map((e) => [e.key, e.value]))
return {
async get (key) {
if (!store.has(key)) {
const err = new Error('not found')
err.notFound = true
throw err
}
return store.get(key)
},
async * iterator (opts = {}) {
const { gte, lte } = opts
let keys = Array.from(store.keys()).sort()
if (gte !== undefined) keys = keys.filter((k) => k >= gte)
if (lte !== undefined) keys = keys.filter((k) => k <= lte)
for (const key of keys) {
yield [key, store.get(key)]
}
}
}
}
function makeQuery (entries) {
return new TopicQuery({
roomsDb: makeRoomsDb(entries),
postsDb: {}
})
}
// Random set of rooms and topic follow records. Each room has some candidate
// addresses; not every address follows, and a follow may later be unfollowed.
function followSetGen () {
return () => {
const roomCount = intGen(rng, 1, 4)()
const rooms = []
const entries = []
for (let i = 0; i < roomCount; i++) {
const room = `room-${i}`
rooms.push(room)
const addrCount = intGen(rng, 1, 6)()
for (let j = 0; j < addrCount; j++) {
const addr = `bitcoincash:q${i}-${j}`
if (rng() < 0.35) continue
entries.push({
key: `${room}:${addr}`,
value: {
type: 'follow',
room,
addr,
unfollow: rng() < 0.4
}
})
}
// A stray post entry in the same room must not count as a follower.
if (rng() < 0.5) {
entries.push({
key: `${room}:post-${i}`,
value: { type: 'post', room }
})
}
}
return { rooms, entries }
}
}
test('listRoomFollowers returns exactly the active follow addresses for a room', async () => {
await forAll(
followSetGen(),
async ({ rooms, entries }) => {
const query = makeQuery(entries)
for (const room of rooms) {
const got = await query.listRoomFollowers(room)
const expected = Array.from(
new Set(
entries
.filter((e) => e.value.type === 'follow' && e.value.room === room && e.value.unfollow !== true && e.value.addr)
.map((e) => e.value.addr)
)
)
const want = expected.sort((a, b) => a.localeCompare(b))
const received = got.slice().sort((a, b) => a.localeCompare(b))
if (JSON.stringify(received) !== JSON.stringify(want)) return false
}
return true
},
{ label: 'listRoomFollowers conservation' }
)
})
test('isFollowingRoom is true exactly for active follow records', async () => {
await forAll(
followSetGen(),
async ({ entries }) => {
const query = makeQuery(entries)
for (const e of entries) {
if (e.value.type !== 'follow') continue
const { room, addr } = e.value
const following = await query.isFollowingRoom(addr, room)
if (following !== (e.value.unfollow !== true)) return false
}
// A room:addr never recorded is never following.
const unseen = await query.isFollowingRoom('bitcoincash:qunseen', 'room-99')
if (unseen !== false) return false
return true
},
{ label: 'isFollowingRoom round trip' }
)
})
test('followAddrFromValue recovers the address from a room:addr record', async () => {
await forAll(
followSetGen(),
({ entries }) => {
const query = makeQuery([])
for (const e of entries) {
if (e.value.type !== 'follow') continue
const addr = query.followAddrFromValue(e.value, e.key)
if (addr !== e.value.addr) return false
}
return true
},
{ label: 'followAddrFromValue recovery' }
)
})
@@ -2,6 +2,41 @@ import { assert } from 'chai'
import sinon from 'sinon'
import TopicQuery from '../../../src/adapters/topic-query.js'
function makeRoomsDb (records = {}) {
const store = new Map(Object.entries(records))
return {
async get (key) {
if (!store.has(key)) {
const err = new Error('not found')
err.notFound = true
throw err
}
return store.get(key)
},
iterator (opts = {}) {
const entries = Array.from(store.entries()).sort((a, b) => a[0].localeCompare(b[0]))
const { gte, lte } = opts
const filtered = entries.filter(([key]) => {
if (gte && key < gte) return false
if (lte && key > lte) return false
return true
})
let i = 0
return {
[Symbol.asyncIterator] () {
return this
},
async next () {
if (i >= filtered.length) return { value: undefined, done: true }
const entry = filtered[i++]
return { value: entry, done: false }
},
async close () {}
}
}
}
}
describe('#TopicQuery', () => {
let uut
let sandbox
@@ -11,7 +46,8 @@ describe('#TopicQuery', () => {
beforeEach(() => {
sandbox = sinon.createSandbox()
roomsDb = {
iterator: sandbox.stub()
iterator: sandbox.stub(),
get: sandbox.stub()
}
postsDb = {
get: sandbox.stub()
@@ -194,4 +230,103 @@ describe('#TopicQuery', () => {
assert.equal(result.total, 2)
})
})
describe('#isFollowingRoom', () => {
it('should return false when no follow record exists', async () => {
const err = new Error('not found')
err.notFound = true
roomsDb.get.withArgs('bitcoin:addr-x').rejects(err)
const result = await uut.isFollowingRoom('addr-x', 'bitcoin')
assert.equal(result, false)
})
it('should return true for an active follow record', async () => {
roomsDb.get.withArgs('bitcoin:addr-a').resolves({ room: 'bitcoin', addr: 'addr-a', type: 'follow', unfollow: false })
const result = await uut.isFollowingRoom('addr-a', 'bitcoin')
assert.equal(result, true)
})
it('should return false for an unfollow record', async () => {
roomsDb.get.withArgs('bitcoin:addr-c').resolves({ room: 'bitcoin', addr: 'addr-c', type: 'follow', unfollow: true })
const result = await uut.isFollowingRoom('addr-c', 'bitcoin')
assert.equal(result, false)
})
it('should return false for a non-follow record', async () => {
roomsDb.get.withArgs('bitcoin:addr-a').resolves({ room: 'bitcoin', txid: 'post-1', type: 'post' })
const result = await uut.isFollowingRoom('addr-a', 'bitcoin')
assert.equal(result, false)
})
it('should rethrow non-not-found errors', async () => {
roomsDb.get.withArgs('bitcoin:addr-a').rejects(new Error('db down'))
try {
await uut.isFollowingRoom('addr-a', 'bitcoin')
assert.fail('Expected error')
} catch (err) {
assert.include(err.message, 'db down')
}
})
})
describe('#listRoomFollowers', () => {
it('should return active followers for a room', async () => {
const query = new TopicQuery({
roomsDb: makeRoomsDb({
'bitcoin:addr-a': { room: 'bitcoin', addr: 'addr-a', type: 'follow', unfollow: false },
'bitcoin:addr-b': { room: 'bitcoin', addr: 'addr-b', type: 'follow', unfollow: false },
'bitcoin:addr-c': { room: 'bitcoin', addr: 'addr-c', type: 'follow', unfollow: true },
'cash:addr-a': { room: 'cash', addr: 'addr-a', type: 'follow', unfollow: false }
}),
postsDb
})
const result = await query.listRoomFollowers('bitcoin')
assert.deepEqual(result, ['addr-a', 'addr-b'])
})
it('should fall back to the key address when the value has no addr', async () => {
const query = new TopicQuery({
roomsDb: makeRoomsDb({
'bitcoin:addr-a': { room: 'bitcoin', type: 'follow', unfollow: false }
}),
postsDb
})
const result = await query.listRoomFollowers('bitcoin')
assert.deepEqual(result, ['addr-a'])
})
it('should return an empty array for a room with no followers', async () => {
const query = new TopicQuery({
roomsDb: makeRoomsDb({}),
postsDb
})
const result = await query.listRoomFollowers('lone')
assert.deepEqual(result, [])
})
})
describe('#followAddrFromValue', () => {
it('should return the addr from the value when present', () => {
const result = uut.followAddrFromValue({ addr: 'addr-a' }, 'bitcoin:addr-a')
assert.equal(result, 'addr-a')
})
it('should fall back to the last key segment when the value has no addr', () => {
const result = uut.followAddrFromValue({ room: 'bitcoin', type: 'follow' }, 'bitcoin:addr-a')
assert.equal(result, 'addr-a')
})
it('should return null when the key has no address segment', () => {
const result = uut.followAddrFromValue({ room: 'lone', type: 'follow' }, 'lone')
assert.equal(result, null)
})
})
})
@@ -24,6 +24,19 @@ describe('#TopicsRESTController', () => {
posts: [{ txid: 'post-300', blockHeight: 300 }],
pagination: { limit: 100, offset: 0, total: 1, hasMore: false }
})
},
topicFollowState: {
execute: sandbox.stub().resolves({
room: 'bitcoin',
addr: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d',
following: true
})
},
listTopicFollowers: {
execute: sandbox.stub().resolves({
room: 'bitcoin',
followers: ['bitcoincash:a', 'bitcoincash:b']
})
}
}
})
@@ -59,6 +72,38 @@ describe('#TopicsRESTController', () => {
assert.equal(ctx.body.posts[0].txid, 'post-300')
})
it('should return topic follow state from use case', async () => {
const ctx = {
params: { room: 'bitcoin' },
query: { addr: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' },
body: null,
throw: sandbox.stub()
}
await uut.getTopicFollowState(ctx)
assert.equal(uut.useCases.topicFollowState.execute.callCount, 1)
assert.deepEqual(uut.useCases.topicFollowState.execute.firstCall.args[0], {
room: 'bitcoin',
addr: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
})
assert.equal(ctx.body.following, true)
})
it('should return topic followers from use case', async () => {
const ctx = {
params: { room: 'bitcoin' },
body: null,
throw: sandbox.stub()
}
await uut.getTopicFollowers(ctx)
assert.equal(uut.useCases.listTopicFollowers.execute.callCount, 1)
assert.deepEqual(uut.useCases.listTopicFollowers.execute.firstCall.args[0], {
room: 'bitcoin'
})
assert.deepEqual(ctx.body.followers, ['bitcoincash:a', 'bitcoincash:b'])
})
it('should throw a 500 when the use case fails without a status', async () => {
uut.useCases.listTopics.execute = sandbox.stub().rejects(new Error('boom'))
const ctx = { body: null, throw: sandbox.stub() }
@@ -0,0 +1,70 @@
import { assert } from 'chai'
import ListTopicFollowers from '../../../src/use-cases/list-topic-followers.js'
function makeAdapters (followers) {
return {
topicQuery: {
async listRoomFollowers (room) {
return followers
}
}
}
}
describe('#ListTopicFollowers', () => {
it('should throw when adapters is missing', () => {
assert.throws(() => new ListTopicFollowers({}), /Adapters required/)
})
it('should throw when topicQuery adapter is missing', () => {
assert.throws(() => new ListTopicFollowers({ adapters: {} }), /topicQuery adapter required/)
})
it('should return the followers list', async () => {
const useCase = new ListTopicFollowers({ adapters: makeAdapters(['bitcoincash:a', 'bitcoincash:b']) })
const result = await useCase.execute({ room: 'bitcoin' })
assert.deepEqual(result, {
room: 'bitcoin',
followers: ['bitcoincash:a', 'bitcoincash:b']
})
})
it('should return an empty list', async () => {
const useCase = new ListTopicFollowers({ adapters: makeAdapters([]) })
const result = await useCase.execute({ room: 'lone' })
assert.deepEqual(result, {
room: 'lone',
followers: []
})
})
it('should reject a missing room', async () => {
const useCase = new ListTopicFollowers({ adapters: makeAdapters([]) })
try {
await useCase.execute({})
assert.fail('expected error')
} catch (err) {
assert.match(err.message, /room is required/)
}
})
it('should reject an empty-string room', async () => {
const useCase = new ListTopicFollowers({ adapters: makeAdapters([]) })
try {
await useCase.execute({ room: '' })
assert.fail('expected error')
} catch (err) {
assert.match(err.message, /room is required/)
}
})
it('should reject a non-string room', async () => {
const useCase = new ListTopicFollowers({ adapters: makeAdapters([]) })
try {
await useCase.execute({ room: 42 })
assert.fail('expected error')
} catch (err) {
assert.match(err.message, /room is required/)
}
})
})
@@ -0,0 +1,92 @@
import { assert } from 'chai'
import TopicFollowState from '../../../src/use-cases/topic-follow-state.js'
function makeAdapters (followingResult) {
return {
topicQuery: {
async isFollowingRoom (addr, room) {
return followingResult
}
}
}
}
describe('#TopicFollowState', () => {
it('should throw when adapters is missing', () => {
assert.throws(() => new TopicFollowState({}), /Adapters required/)
})
it('should throw when topicQuery adapter is missing', () => {
assert.throws(() => new TopicFollowState({ adapters: {} }), /topicQuery adapter required/)
})
it('should return following=true', async () => {
const useCase = new TopicFollowState({ adapters: makeAdapters(true) })
const result = await useCase.execute({ room: 'bitcoin', addr: 'bitcoincash:addr-a' })
assert.deepEqual(result, {
room: 'bitcoin',
addr: 'bitcoincash:addr-a',
following: true
})
})
it('should return following=false', async () => {
const useCase = new TopicFollowState({ adapters: makeAdapters(false) })
const result = await useCase.execute({ room: 'bitcoin', addr: 'bitcoincash:addr-a' })
assert.deepEqual(result, {
room: 'bitcoin',
addr: 'bitcoincash:addr-a',
following: false
})
})
it('should reject a missing room', async () => {
const useCase = new TopicFollowState({ adapters: makeAdapters(false) })
try {
await useCase.execute({ addr: 'bitcoincash:addr-a' })
assert.fail('expected error')
} catch (err) {
assert.match(err.message, /room is required/)
}
})
it('should reject a missing addr', async () => {
const useCase = new TopicFollowState({ adapters: makeAdapters(false) })
try {
await useCase.execute({ room: 'bitcoin' })
assert.fail('expected error')
} catch (err) {
assert.match(err.message, /addr is required/)
}
})
it('should reject an empty-string room', async () => {
const useCase = new TopicFollowState({ adapters: makeAdapters(false) })
try {
await useCase.execute({ room: '', addr: 'bitcoincash:addr-a' })
assert.fail('expected error')
} catch (err) {
assert.match(err.message, /room is required/)
}
})
it('should reject an empty-string addr', async () => {
const useCase = new TopicFollowState({ adapters: makeAdapters(false) })
try {
await useCase.execute({ room: 'bitcoin', addr: '' })
assert.fail('expected error')
} catch (err) {
assert.match(err.message, /addr is required/)
}
})
it('should reject a non-string room', async () => {
const useCase = new TopicFollowState({ adapters: makeAdapters(false) })
try {
await useCase.execute({ room: 42, addr: 'bitcoincash:addr-a' })
assert.fail('expected error')
} catch (err) {
assert.match(err.message, /room is required/)
}
})
})