diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 73f7164..898336d 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -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 "?(|[^"]+)"?$/, + pattern: /^I open the topic feed for (?:the 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 ()$/, + 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 ()$/, + 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 ()$/, + 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 ()$/, + 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 ()$/, + 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 ()$/, + 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}.`) + } + } } ] diff --git a/psf-memo-client/specs/topic-follow.feature b/psf-memo-client/specs/topic-follow.feature index 1a3448a..4ad4b4a 100644 --- a/psf-memo-client/specs/topic-follow.feature +++ b/psf-memo-client/specs/topic-follow.feature @@ -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 diff --git a/psf-memo-client/specs/topic-post.feature b/psf-memo-client/specs/topic-post.feature index da0719d..4db0dbf 100644 --- a/psf-memo-client/specs/topic-post.feature +++ b/psf-memo-client/specs/topic-post.feature @@ -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 diff --git a/psf-memo-client/src/components/app-body/topic-feed/index.js b/psf-memo-client/src/components/app-body/topic-feed/index.js index 33ae1ad..1086f9e 100644 --- a/psf-memo-client/src/components/app-body/topic-feed/index.js +++ b/psf-memo-client/src/components/app-body/topic-feed/index.js @@ -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 (
-

#{room}

-

Posts published in the {room} topic.

+
+
+

#{room}

+

Posts published in the {room} topic.

+
+ + {showFollowButton && ( + + )} +
{pagination && posts.length > 0 && ( @@ -99,8 +254,42 @@ function TopicFeed (props) { {pagination.offset + posts.length} of {pagination.total} )} + + {followers.length > 0 && ( + + {followers.length} follower{followers.length === 1 ? '' : 's'} + + )}
+ {showComposer && ( +
+ + + + +
+

+ {remainingBytes()} bytes remaining +

+ + +
+ + {composerErr && ( +

{composerErr}

+ )} +
+ )} + {error && (

{error} diff --git a/psf-memo-client/src/services/memo-db.js b/psf-memo-client/src/services/memo-db.js index f4c2aeb..a4c15bf 100644 --- a/psf-memo-client/src/services/memo-db.js +++ b/psf-memo-client/src/services/memo-db.js @@ -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 diff --git a/psf-memo-client/src/services/memo-topic-follow.js b/psf-memo-client/src/services/memo-topic-follow.js new file mode 100644 index 0000000..b7c614d --- /dev/null +++ b/psf-memo-client/src/services/memo-topic-follow.js @@ -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 diff --git a/psf-memo-client/src/services/memo-topic-post.js b/psf-memo-client/src/services/memo-topic-post.js new file mode 100644 index 0000000..7d08b70 --- /dev/null +++ b/psf-memo-client/src/services/memo-topic-post.js @@ -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 diff --git a/psf-memo-client/src/services/profiles.js b/psf-memo-client/src/services/profiles.js index 72e0cd9..7ed2d6d 100644 --- a/psf-memo-client/src/services/profiles.js +++ b/psf-memo-client/src/services/profiles.js @@ -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 diff --git a/psf-memo-client/src/services/topic-feed-page.js b/psf-memo-client/src/services/topic-feed-page.js index fd222a1..4b16ca4 100644 --- a/psf-memo-client/src/services/topic-feed-page.js +++ b/psf-memo-client/src/services/topic-feed-page.js @@ -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 diff --git a/psf-memo-client/src/services/topic-post-page.js b/psf-memo-client/src/services/topic-post-page.js new file mode 100644 index 0000000..d305db9 --- /dev/null +++ b/psf-memo-client/src/services/topic-post-page.js @@ -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 diff --git a/psf-memo-client/test/property/topic-post.property.test.js b/psf-memo-client/test/property/topic-post.property.test.js new file mode 100644 index 0000000..6138b24 --- /dev/null +++ b/psf-memo-client/test/property/topic-post.property.test.js @@ -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' } + ) +}) diff --git a/psf-memo-client/test/property/topic-services.property.test.js b/psf-memo-client/test/property/topic-services.property.test.js index fa5b1c0..2d7dc6d 100644 --- a/psf-memo-client/test/property/topic-services.property.test.js +++ b/psf-memo-client/test/property/topic-services.property.test.js @@ -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() diff --git a/psf-memo-client/test/unit/memo-topic-follow.test.js b/psf-memo-client/test/unit/memo-topic-follow.test.js new file mode 100644 index 0000000..ad1895b --- /dev/null +++ b/psf-memo-client/test/unit/memo-topic-follow.test.js @@ -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/ + ) +}) diff --git a/psf-memo-client/test/unit/memo-topic-post.test.js b/psf-memo-client/test/unit/memo-topic-post.test.js new file mode 100644 index 0000000..a6f21f4 --- /dev/null +++ b/psf-memo-client/test/unit/memo-topic-post.test.js @@ -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/ + ) +}) diff --git a/psf-memo-client/test/unit/profiles.test.js b/psf-memo-client/test/unit/profiles.test.js index 531a0bb..98d5f1f 100644 --- a/psf-memo-client/test/unit/profiles.test.js +++ b/psf-memo-client/test/unit/profiles.test.js @@ -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' diff --git a/psf-memo-client/test/unit/topic-feed-page.test.js b/psf-memo-client/test/unit/topic-feed-page.test.js index 89e2e17..92a6c18 100644 --- a/psf-memo-client/test/unit/topic-feed-page.test.js +++ b/psf-memo-client/test/unit/topic-feed-page.test.js @@ -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') }) diff --git a/psf-memo-client/test/unit/topic-post-page.test.js b/psf-memo-client/test/unit/topic-post-page.test.js new file mode 100644 index 0000000..cdda058 --- /dev/null +++ b/psf-memo-client/test/unit/topic-post-page.test.js @@ -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) +}) diff --git a/psf-memo-db/acceptance/lib/handlers.js b/psf-memo-db/acceptance/lib/handlers.js index 78c20ea..124442e 100644 --- a/psf-memo-db/acceptance/lib/handlers.js +++ b/psf-memo-db/acceptance/lib/handlers.js @@ -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 ()$/, + 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 ()$/, + 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(',')}`) + } + } } ] diff --git a/psf-memo-db/specs/topic-follow-read.feature b/psf-memo-db/specs/topic-follow-read.feature index a86a6c7..d9a8271 100644 --- a/psf-memo-db/specs/topic-follow-read.feature +++ b/psf-memo-db/specs/topic-follow-read.feature @@ -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}` diff --git a/psf-memo-db/src/adapters/topic-query.js b/psf-memo-db/src/adapters/topic-query.js index 2b340f0..07f6d8d 100644 --- a/psf-memo-db/src/adapters/topic-query.js +++ b/psf-memo-db/src/adapters/topic-query.js @@ -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 diff --git a/psf-memo-db/src/controllers/rest-api/topics/controller.js b/psf-memo-db/src/controllers/rest-api/topics/controller.js index 67e8417..2ebda83 100644 --- a/psf-memo-db/src/controllers/rest-api/topics/controller.js +++ b/psf-memo-db/src/controllers/rest-api/topics/controller.js @@ -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 diff --git a/psf-memo-db/src/controllers/rest-api/topics/index.js b/psf-memo-db/src/controllers/rest-api/topics/index.js index d092516..7b83997 100644 --- a/psf-memo-db/src/controllers/rest-api/topics/index.js +++ b/psf-memo-db/src/controllers/rest-api/topics/index.js @@ -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()) } diff --git a/psf-memo-db/src/use-cases/index.js b/psf-memo-db/src/use-cases/index.js index 3f9b371..eecc365 100644 --- a/psf-memo-db/src/use-cases/index.js +++ b/psf-memo-db/src/use-cases/index.js @@ -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 diff --git a/psf-memo-db/src/use-cases/list-topic-followers.js b/psf-memo-db/src/use-cases/list-topic-followers.js new file mode 100644 index 0000000..204e270 --- /dev/null +++ b/psf-memo-db/src/use-cases/list-topic-followers.js @@ -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 diff --git a/psf-memo-db/src/use-cases/topic-follow-state.js b/psf-memo-db/src/use-cases/topic-follow-state.js new file mode 100644 index 0000000..6fe9031 --- /dev/null +++ b/psf-memo-db/src/use-cases/topic-follow-state.js @@ -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 diff --git a/psf-memo-db/test/property/topic-follow-query.property.test.js b/psf-memo-db/test/property/topic-follow-query.property.test.js new file mode 100644 index 0000000..3e0b3eb --- /dev/null +++ b/psf-memo-db/test/property/topic-follow-query.property.test.js @@ -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' } + ) +}) diff --git a/psf-memo-db/test/unit/adapters/topic-query.unit.js b/psf-memo-db/test/unit/adapters/topic-query.unit.js index 37a71e6..f10e444 100644 --- a/psf-memo-db/test/unit/adapters/topic-query.unit.js +++ b/psf-memo-db/test/unit/adapters/topic-query.unit.js @@ -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) + }) + }) }) diff --git a/psf-memo-db/test/unit/controllers/topics.controller.unit.js b/psf-memo-db/test/unit/controllers/topics.controller.unit.js index afd7851..3cb34dd 100644 --- a/psf-memo-db/test/unit/controllers/topics.controller.unit.js +++ b/psf-memo-db/test/unit/controllers/topics.controller.unit.js @@ -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() } diff --git a/psf-memo-db/test/unit/use-cases/list-topic-followers.unit.js b/psf-memo-db/test/unit/use-cases/list-topic-followers.unit.js new file mode 100644 index 0000000..34f502c --- /dev/null +++ b/psf-memo-db/test/unit/use-cases/list-topic-followers.unit.js @@ -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/) + } + }) +}) diff --git a/psf-memo-db/test/unit/use-cases/topic-follow-state.unit.js b/psf-memo-db/test/unit/use-cases/topic-follow-state.unit.js new file mode 100644 index 0000000..4c3747c --- /dev/null +++ b/psf-memo-db/test/unit/use-cases/topic-follow-state.unit.js @@ -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/) + } + }) +})