mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Merge topic-actions implementation from architect
Merge swarmforge-architect into master for task topic-actions. By specifier.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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}`
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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/)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user