diff --git a/docs/reviews/post-options-menu-summary.md b/docs/reviews/post-options-menu-summary.md new file mode 100644 index 0000000..8248fd5 --- /dev/null +++ b/docs/reviews/post-options-menu-summary.md @@ -0,0 +1,117 @@ +# post-options-menu — Architect Review + +Task: `post-options-menu` +Component: `psf-memo-client` +Base: `5e62d1e` (last merged architect review); inbound refactorer commit `d0882bb` + +## What was reviewed + +Inbound refactorer batch (priority 10), merged onto `swarmforge-architect` by +fast-forwarding to `d0882bb`. The linear chain reviewed: + +- **`091272d`** — specifier: *Record feed-total-cap completion in backlog and briefing*. +- **`bc964fa`** — specifier: *Specify post options menu for all post cards*. Adds + `psf-memo-client/specs/post-options-menu.feature` (9 scenario outlines across the + recent/following/topic/thread/profile surfaces) and the backlog entry. +- **`20e67da`** — coder: *Implement post options menu on every post card*. Adds the + pure `src/services/post-options.js`, the `src/components/post-feed/post-options-menu.js` + shared React component, the acceptance render adapter, the post-options acceptance + handlers, and unit tests; wires the menu into `post-feed-item.js` and `profile/index.js`. +- **`d0882bb`** — refactorer: *Refactor post options menu and add property tests*. + Moves the key-command mapping into `postOptionsKeyCommand` in the pure service so the + component is a thin adapter, DRYs the duplicated acceptance step bodies behind + `togglePostOptionsMenu` / `transitionActivePostOptionsMenu`, and adds + `test/property/post-options.property.test.js`. + +**Architect review commit: `07ff61392b`** — the two `mutate4javascript` manifest +footers, the soft `gherkin-mutator` acceptance-mutation manifest stamp on +`post-options-menu.feature`, and the hardening changes described below. The +summary and verification record are committed on top, so +`git diff 07ff61392b HEAD` touches only `docs/`. The record's `git_sha` is +`07ff61392b`, the commit that contains the verified source state. + +## Architectural findings and fixes applied + +The refactorer's structure is sound: a framework-free core service, a React +adapter, and a separate acceptance render adapter. Two testability gaps found +by language mutation were fixed with a behavior-preserving extraction. + +1. **UI/Core separation.** `src/services/post-options.js` is plain CommonJS with + no React, DOM, or IO; it owns URL construction and the open/close/focus + transitions. The React component consumes it through `require` and only maps + state to markup. The acceptance pipeline reaches the same service through + `acceptance/lib/render-post-options.js`, which server-renders the actual + component. Core behavior is exercised with no browser. +2. **Dependency rule.** The service depends on nothing; the component and the + acceptance adapter depend inward on the service. No framework or persistence + structure leaks across the boundary. +3. **Uncovered mutation / testable boundary.** The outside-click decision was an + inline `containerRef.current && !containerRef.current.contains(event.target)` + inside `useEffect`, which the unit (`node --test`) harness never executes, so + the `&& -> ||` mutation was **uncovered**. Extracted it to the pure + `isOutsidePostOptions(container, target)` in the service; the effect is now a + thin adapter and the decision is unit tested. This follows the role rule to + maximize testable modules and minimize the environmentally unsuitable shell. +4. **Mutation survivors.** Two `-1` default-focus literals survived because no + unit test rendered an *open* menu with the default (unfocused) state: the + `initialFocusedIndex = -1` default and the `tabIndex ... : -1` else branch. + Added a unit test asserting the default open menu makes no item tabbable, + killing both. (Property tests are a separate suite and are intentionally not + part of the language-mutation test command.) +5. **Duplication.** The refactorer's acceptance-step DRY is local and clear; the + new service helper adds no duplication. `dry4javascript` reported 96 + duplicate blocks, all pre-existing `acceptance/lib/handlers.js` + step-handler boilerplate; none involves the post-options modules. +6. **Test/helper separation.** `render-post-options.js` (acceptance helper) and + the property-test generators live in their helper libraries, separate from + the unit and property test files. + +## Verification results + +### Language mutation (`mutate4javascript`, differential, `--max-workers 8`) + +Initial component run before the fix: **3 killed, 2 survived, 1 uncovered**. +After the extraction and unit tests: + +- **`src/services/post-options.js`**: 10 covered sites. Differential selected + 1 of 10 after the new function; `mutate-file.sh` auto-reran `--mutate-all`: + **10 killed, 0 survived, 0 uncovered**. +- **`src/components/post-feed/post-options-menu.js`**: **5 killed, 0 survived, + 0 uncovered**. + +### DRY (`dry4javascript`) + +Changed source/tests/handlers plus `acceptance/lib/handlers.js`: +**no duplicate candidate involves the new code**. 96 reported blocks are +pre-existing handler step-handler boilerplate, consistent with prior reviews. + +### CRAP / cyclomatic complexity (`crap4javascript`) + +All changed functions below the 8.0 threshold: +`PostOptionsMenu` (CC 3, 81.8% covered, CRAP 3.1 — the uncovered remainder is the +DOM `useEffect` shell), `postOptionsKeyCommand` (CC 3, CRAP 3.0), +`isOutsidePostOptions` (CC 2, 100%, CRAP 2.0), and the remaining service +accessors (CC 1–2, 100%). + +### Soft Gherkin acceptance mutation (`gherkin-mutator --level soft`) + +- **`post-options-menu.feature`**: **20 executed, 20 killed, 0 survived, + 0 errors**. All nine scenario outlines killed every soft mutation, so the tool + wrote a full-feature acceptance-mutation manifest stamp (no survivors to + document). + +### Suite status + +`swarmforge/scripts/verify.sh client --record +docs/reviews/post-options-menu-verification.json --task post-options-menu` +-> **pass (5/5)**: +unit **364 passing**, property **68 passing**, acceptance **all 28 suites +passing**, lint **pass**, build **pass**. Record `git_sha` = `07ff61392b`. + +## Handoffs sent +- End-of-chain `git_handoff` to the specifier (task `post-options-menu`) with the + review commit so it can merge `swarmforge-architect` into `master`. +- No coder/refactorer handoff: the review is test hardening plus a + behavior-preserving extraction with no follow-up work for them. + +By architect. diff --git a/docs/reviews/post-options-menu-verification.json b/docs/reviews/post-options-menu-verification.json new file mode 100644 index 0000000..15f9fae --- /dev/null +++ b/docs/reviews/post-options-menu-verification.json @@ -0,0 +1,46 @@ +{ + "schema_version": 1, + "task": "post-options-menu", + "component": "psf-memo-client", + "git_sha": "07ff61392b345547d320e71fd01594367afc1125", + "branch": "swarmforge-architect", + "timestamp": "2026-09-16T17:04:09.878Z", + "commands": [ + { + "name": "unit", + "command": "npm test", + "exit": 0, + "duration_ms": 8834, + "summary": "364 pass / 0 fail" + }, + { + "name": "property", + "command": "npm run test:property", + "exit": 0, + "duration_ms": 9064, + "summary": "68 pass / 0 fail" + }, + { + "name": "acceptance", + "command": "npm run test:acceptance", + "exit": 0, + "duration_ms": 13642, + "summary": "all 28 acceptance suites passed" + }, + { + "name": "lint", + "command": "npm run lint", + "exit": 0, + "duration_ms": 2283, + "summary": "ok" + }, + { + "name": "build", + "command": "npm run build", + "exit": 0, + "duration_ms": 64231, + "summary": "ok" + } + ], + "result": "pass" +} diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 99396eb..c128a24 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -54,6 +54,8 @@ const MemoPollVote = require('../../src/services/memo-poll-vote') const PollVotePage = require('../../src/services/poll-vote-page') const { renderPostText } = require('./render-post') const { renderAccountAvatar } = require('./render-account-avatar') +const { renderPostOptions } = require('./render-post-options') +const PostOptions = require('../../src/services/post-options') const { YOUTUBE_EMBED_BASE_URL } = require('../../src/services/youtube-embed') const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX @@ -3260,9 +3262,173 @@ const handlers = [ renderPostText(post.text, { initialFailedImages: [...world.failedImages] }) ) } + }, + { + name: 'page shows a post options button for the post', + pattern: /^the page shows a post options button for the post with txid (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const post = findPostOnCurrentPage(txid, world) + if (!post) { + throw new Error(`The displayed page does not show a post with txid ${txid}.`) + } + const menu = getPostOptionsMenu(world, txid) + const html = renderPostOptions(txid, { open: menu.open }) + if (!html.includes('aria-label="Post options"')) { + throw new Error(`Post ${txid} does not render a post options button.`) + } + } + }, + { + name: 'post options menu is hidden', + pattern: /^the post options menu is hidden for the post with txid (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const menu = getPostOptionsMenu(world, txid) + if (menu.open) { + throw new Error(`Post options menu for ${txid} is open, but it should be hidden.`) + } + const html = renderPostOptions(txid, { open: menu.open }) + if (html.includes(PostOptions.BLOCK_EXPLORER_LABEL)) { + throw new Error(`Post options menu for ${txid} renders items while hidden.`) + } + } + }, + { + name: 'click post options button', + pattern: /^I click the post options button for the post with txid (.+)$/, + run (m, example, world) { + togglePostOptionsMenu(world, resolveParam(m[1], example)) + } + }, + { + name: 'click post options button again', + pattern: /^I click the post options button again for the post with txid (.+)$/, + run (m, example, world) { + togglePostOptionsMenu(world, resolveParam(m[1], example)) + } + }, + { + name: 'post options menu is shown', + pattern: /^the post options menu is shown for the post with txid (.+)$/, + run (m, example, world) { + const txid = resolveParam(m[1], example) + const menu = getPostOptionsMenu(world, txid) + if (!menu.open) { + throw new Error(`Post options menu for ${txid} is hidden, but it should be shown.`) + } + } + }, + { + name: 'first post options menu item label', + pattern: /^the first item in the post options menu is "(.+)"$/, + run (m, example, world) { + const label = m[1] + const items = PostOptions.postOptionsItems(world.activeMenuTxid) + if (!items[0] || items[0].label !== label) { + throw new Error(`Expected the first post options item to be "${label}".`) + } + } + }, + { + name: 'first post options menu item links in new tab', + pattern: /^the first post options menu item links to (.+) and opens in a new tab$/, + run (m, example, world) { + const href = resolveParam(m[1], example) + const txid = world.activeMenuTxid + const first = PostOptions.postOptionsItems(txid)[0] + if (!first || first.href !== href) { + throw new Error(`Expected the first post options item to link to ${href}.`) + } + if (first.target !== '_blank') { + throw new Error('Expected the first post options item to open in a new tab.') + } + const html = renderPostOptions(txid, { open: true, focusedIndex: 0 }) + if (!html.includes(`href="${href}"`) || !html.includes('target="_blank"')) { + throw new Error(`The rendered post options menu does not link to ${href} in a new tab.`) + } + if (!html.includes(PostOptions.BLOCK_EXPLORER_LABEL)) { + throw new Error('The rendered post options menu does not show the block explorer item.') + } + } + }, + { + name: 'click outside post options menu', + pattern: /^I click outside the post options menu$/, + run (m, example, world) { + transitionActivePostOptionsMenu(world, PostOptions.handlePostOptionsOutsideClick) + } + }, + { + name: 'press Escape key', + pattern: /^I press the Escape key$/, + run (m, example, world) { + transitionActivePostOptionsMenu(world, PostOptions.handlePostOptionsEscape) + } + }, + { + name: 'press ArrowDown key', + pattern: /^I press the ArrowDown key$/, + run (m, example, world) { + const txid = world.activeMenuTxid + transitionActivePostOptionsMenu(world, (menu) => + PostOptions.focusFirstPostOption(menu, PostOptions.postOptionsItems(txid)) + ) + } + }, + { + name: 'first post options menu item has focus', + pattern: /^the first post options menu item has focus$/, + run (m, example, world) { + const txid = world.activeMenuTxid + const menu = getPostOptionsMenu(world, txid) + if (menu.focusedIndex !== 0) { + throw new Error('Expected the first post options item to have focus.') + } + const html = renderPostOptions(txid, { open: true, focusedIndex: 0 }) + if (!html.includes('tabindex="0"')) { + throw new Error('The rendered first post options item is not focusable.') + } + } } ] +// Find the post options menu state for a txid, creating a closed one on first use. +function getPostOptionsMenu (world, txid) { + if (!world.postOptionsMenus) world.postOptionsMenus = {} + if (!world.postOptionsMenus[txid]) { + world.postOptionsMenus[txid] = { txid, ...PostOptions.initialPostOptionsState() } + } + return world.postOptionsMenus[txid] +} + +// Apply a post options state transition to the active menu. +function transitionActivePostOptionsMenu (world, transition) { + const menu = getPostOptionsMenu(world, world.activeMenuTxid) + Object.assign(menu, transition(menu)) +} + +// Toggle a post's options menu and make it the active menu. +function togglePostOptionsMenu (world, txid) { + const menu = getPostOptionsMenu(world, txid) + Object.assign(menu, PostOptions.togglePostOptions(menu)) + world.activeMenuTxid = txid +} + +// The posts currently rendered by the page the scenario has opened. +function postsOnCurrentPage (world) { + const path = world.currentPath || '' + if (world.threadPage && world.threadPage.rootPost) return world.threadPage.allPosts || [] + if (path.startsWith(ProfilePage.PROFILE_PATH_PREFIX)) return world.profilePage?.posts || [] + if (path.startsWith('/topics/')) return world.topicFeedPage?.posts || [] + if (path === FollowingFeedPage.FOLLOWING_FEED_PATH) return world.followingFeedPage?.posts || [] + return world.recentFeedPage?.posts || [] +} + +function findPostOnCurrentPage (txid, world) { + return postsOnCurrentPage(world).find((post) => post && post.txid === txid) || null +} + // Return the cached rendered feed HTML, computing it on first use. function getRenderedFeed (world) { if (!world.renderedFeed) { diff --git a/psf-memo-client/acceptance/lib/render-post-options.js b/psf-memo-client/acceptance/lib/render-post-options.js new file mode 100644 index 0000000..20475bd --- /dev/null +++ b/psf-memo-client/acceptance/lib/render-post-options.js @@ -0,0 +1,25 @@ +/* + Acceptance rendering adapter for the shared post options menu. + + Renders the same PostOptionsMenu component the browser uses to a static HTML + string, so acceptance assertions can inspect the button and menu markup + without running a browser. The component's open/closed and focused state can + be seeded through props for deterministic rendering. +*/ + +'use strict' + +const React = require('react') +const ReactDOMServer = require('react-dom/server') +const PostOptionsMenu = require('../../src/components/post-feed/post-options-menu') + +function renderPostOptions (txid, options = {}) { + const element = React.createElement(PostOptionsMenu, { + txid, + initialOpen: options.open, + initialFocusedIndex: options.focusedIndex + }) + return ReactDOMServer.renderToStaticMarkup(element) +} + +module.exports = { renderPostOptions } diff --git a/psf-memo-client/specs/post-options-menu.feature b/psf-memo-client/specs/post-options-menu.feature index 0c106c8..b9d6e3b 100644 --- a/psf-memo-client/specs/post-options-menu.feature +++ b/psf-memo-client/specs/post-options-menu.feature @@ -1,3 +1,8 @@ +# mutation-stamp: sha256=d6155399b60e3d5fda876a8b4dbc415d8b564bf233a6d61c67c8beae57ebc1da +# acceptance-mutation-manifest-begin +# {"version":1,"tested_at":"2026-09-16T16:59:26.923749533Z","feature_name":"Post Options Menu","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/post-options-menu.feature","background_hash":"0d66780cb1b8e277f0ada40a8ffe336dec7a8eaf658f19d2ea344815fb9bf26c","implementation_hash":"unknown","scenarios":[{"index":0,"name":"Post Options Menu - 1 the post menu shows the block explorer link as its first item","scenario_hash":"9a82c0e05992fc1a2ad3e57acfe4796434a95310511dac60cb18de613be13be0","mutation_count":4,"result":{"Total":4,"Killed":4,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":1,"name":"Post Options Menu - 2 clicking the post options button again closes the menu","scenario_hash":"723bdf16c5aee245b0e331a72ddbbc590fce23e8d9f46b232cc30658541873fc","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":2,"name":"Post Options Menu - 3 clicking outside the menu closes it","scenario_hash":"3cfd93550ed4b484efbd6c0572cbd6efaf61b3554a3e163e5aef34f43d4f1297","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":3,"name":"Post Options Menu - 4 pressing Escape closes the menu","scenario_hash":"109c8bd553e858e63d39639230981f1714fa0cff71c2abbcfeea69047aebbb12","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":4,"name":"Post Options Menu - 5 arrow keys move focus to the menu's first item","scenario_hash":"4f2c2c2f368c06fb399f4082398c3b41e423b46e55477b82296cedfe9c8a1f35","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":5,"name":"Post Options Menu - 6 the following feed post menu offers the block explorer link","scenario_hash":"49a9c0201d5a42ecbafaef4c4460248d1bf67218d7f81ea8cd5c00ac0ed0c57c","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":6,"name":"Post Options Menu - 7 the topic feed post menu offers the block explorer link","scenario_hash":"e96efacd24018eb02f348b53bae27e3d374969b692775f563e5c004ca1b90904","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":7,"name":"Post Options Menu - 8 the thread post menu offers the block explorer link","scenario_hash":"afc34b9045b83690e14e50c2850788787984f0c72324b54aa994ccc319af58d5","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"},{"index":8,"name":"Post Options Menu - 9 the profile post menu offers the block explorer link","scenario_hash":"7b253454da281aa3447d1b1121f9aec8bb9d4efacfb98b61a1232ee9d039c253","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T16:59:26.923749533Z"}]} +# acceptance-mutation-manifest-end + # Scenarios: Post Options Menu - 1, Post Options Menu - 2, Post Options Menu - 3, Post Options Menu - 4, Post Options Menu - 5, Post Options Menu - 6, Post Options Menu - 7, Post Options Menu - 8, Post Options Menu - 9 # # Every post card has a "Post options" button (three dots) in its upper-right diff --git a/psf-memo-client/src/App.css b/psf-memo-client/src/App.css index ff921cd..a3df982 100644 --- a/psf-memo-client/src/App.css +++ b/psf-memo-client/src/App.css @@ -344,6 +344,80 @@ header, border-top-color: var(--border); } +/* Post options menu */ + +.post-options { + position: relative; + flex: 0 0 auto; +} + +.post-options-button { + width: 34px; + height: 34px; + padding: 0; + + color: var(--text-secondary); + background: transparent; + border: 0; + border-radius: var(--radius-round); + box-shadow: none; + + font-size: 15px; + letter-spacing: 1px; + cursor: pointer; +} + +.post-options-button:hover, +.post-options-button:focus-visible { + color: var(--text-primary); + background: var(--surface-muted); + border: 0; + box-shadow: none; +} + +.post-options-menu { + position: absolute; + top: calc(100% + 4px); + right: 0; + z-index: 20; + + min-width: 200px; + margin: 0; + padding: 8px; + list-style: none; + + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-md); + + box-shadow: var(--shadow-menu); +} + +.post-options-menu-item { + margin: 0; + padding: 0; +} + +.post-options-link { + display: block; + padding: 10px 12px; + + color: var(--text-primary); + border-radius: var(--radius-sm); + + font-size: 14px; + font-weight: 500; + text-decoration: none; + white-space: nowrap; +} + +.post-options-link:hover, +.post-options-link:focus { + color: var(--text-primary); + background: var(--surface-muted); + text-decoration: none; +} + /* General page headings */ .main-content h1 { diff --git a/psf-memo-client/src/components/app-body/profile/index.js b/psf-memo-client/src/components/app-body/profile/index.js index 18e7d39..9cdf7e6 100644 --- a/psf-memo-client/src/components/app-body/profile/index.js +++ b/psf-memo-client/src/components/app-body/profile/index.js @@ -14,6 +14,7 @@ import ProfilePage from '../../../services/profile-page' import { getViewerAddress } from '../../../services/profile-wallet' import PostReplyCount from '../../post-reply-count' import LikeButton from '../../post-feed/like-button' +import PostOptionsMenu from '../../post-feed/post-options-menu' import PostThreadModal from '../../post-thread-modal' import '../../../App.css' import './profile.css' @@ -248,9 +249,12 @@ function Profile (props) { {posts.map((post) => ( -
- {formatSeen(post.seen)} - Block {post.blockHeight} +
+
+ {formatSeen(post.seen)} + Block {post.blockHeight} +
+
{post.text}
diff --git a/psf-memo-client/src/components/post-feed/post-feed-item.js b/psf-memo-client/src/components/post-feed/post-feed-item.js index 73f7c4f..82a0f0b 100644 --- a/psf-memo-client/src/components/post-feed/post-feed-item.js +++ b/psf-memo-client/src/components/post-feed/post-feed-item.js @@ -16,6 +16,7 @@ import { truncateTxid } from './post-display' import PostContent from './post-content' +import PostOptionsMenu from './post-options-menu' import './post-feed.css' const appUtil = new AppUtil() @@ -131,14 +132,7 @@ function PostFeedItem ({
- +
diff --git a/psf-memo-client/src/components/post-feed/post-feed.css b/psf-memo-client/src/components/post-feed/post-feed.css index 6bb0fa7..e7d142d 100644 --- a/psf-memo-client/src/components/post-feed/post-feed.css +++ b/psf-memo-client/src/components/post-feed/post-feed.css @@ -499,30 +499,6 @@ gap: 6px; } -.posts-feed-item-menu { - width: 34px; - height: 34px; - padding: 0; - - color: #262626; - background: transparent; - border: 0; - border-radius: 50%; - box-shadow: none; - - font-size: 15px; - letter-spacing: 1px; - cursor: pointer; -} - -.posts-feed-item-menu:hover { - color: #737373; - background: #f2f2f2; - border: 0; - box-shadow: none; - transform: none; -} - .posts-feed-item-content { padding: 14px 16px 6px; } diff --git a/psf-memo-client/src/components/post-feed/post-options-menu.js b/psf-memo-client/src/components/post-feed/post-options-menu.js new file mode 100644 index 0000000..aed0ee8 --- /dev/null +++ b/psf-memo-client/src/components/post-feed/post-options-menu.js @@ -0,0 +1,117 @@ +/* + Shared post options menu. + + Every post card renders this same three-dots menu: a "Post options" button + whose menu's first item is "See on block explorer", a link to the post + transaction on bch.loping.net that opens in a new tab. The menu closes on the + button again, an outside click, or Escape, and ArrowDown moves focus to the + first item. + + Written in plain React.createElement style so the same module can be used by + the JSX components in the browser build and by the acceptance adapter that + renders HTML under Node. +*/ + +const React = require('react') +const { + postOptionsItems, + togglePostOptions, + handlePostOptionsOutsideClick, + isOutsidePostOptions, + postOptionsKeyCommand +} = require('../../services/post-options') + +function PostOptionsMenu ({ + txid, + initialOpen = false, + initialFocusedIndex = -1 +}) { + const [state, setState] = React.useState(() => ({ + open: Boolean(initialOpen), + focusedIndex: initialFocusedIndex + })) + const containerRef = React.useRef(null) + const items = postOptionsItems(txid) + + // Close the open menu when the user clicks anywhere outside it. + React.useEffect(() => { + if (!state.open) return undefined + + const handleDocumentMouseDown = (event) => { + if (isOutsidePostOptions(containerRef.current, event.target)) { + setState((previous) => handlePostOptionsOutsideClick(previous)) + } + } + + document.addEventListener('mousedown', handleDocumentMouseDown) + return () => document.removeEventListener('mousedown', handleDocumentMouseDown) + }, [state.open]) + + const handleKeyDown = (event) => { + const command = postOptionsKeyCommand(event.key, items) + if (!command) return + + if (command.preventDefault) event.preventDefault() + setState((previous) => command.transition(previous)) + } + + return React.createElement( + 'div', + { + className: 'post-options', + ref: containerRef, + onKeyDown: handleKeyDown + }, + React.createElement( + 'button', + { + type: 'button', + className: 'post-options-button', + 'aria-label': 'Post options', + 'aria-haspopup': 'menu', + 'aria-expanded': state.open ? 'true' : 'false', + title: 'Post options', + onClick: () => setState((previous) => togglePostOptions(previous)) + }, + React.createElement('span', { 'aria-hidden': 'true' }, '•••') + ), + state.open && + React.createElement( + 'ul', + { + className: 'post-options-menu', + role: 'menu', + 'aria-label': 'Post options' + }, + items.map((item, index) => + React.createElement( + 'li', + { + key: item.id, + className: 'post-options-menu-item', + role: 'none' + }, + React.createElement( + 'a', + { + href: item.href, + target: item.target, + rel: item.rel, + role: 'menuitem', + className: 'post-options-link', + tabIndex: index === state.focusedIndex ? 0 : -1, + autoFocus: index === state.focusedIndex + }, + item.label + ) + ) + ) + ) + ) +} + +module.exports = PostOptionsMenu + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-09-16T16:54:35.472Z","module_hash":"68c1b9ad46f613f4d84aecc6be2fc0a2e370b9f3c50b15e50f702f6587e93b45","functions":[{"id":"func/PostOptionsMenu","name":"PostOptionsMenu","line":24,"end_line":111,"hash":"df85571487654169765129cbe38d08247311fbd32bd94ad3a395834a202aa69b"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-client/src/services/post-options.js b/psf-memo-client/src/services/post-options.js new file mode 100644 index 0000000..7489ea0 --- /dev/null +++ b/psf-memo-client/src/services/post-options.js @@ -0,0 +1,117 @@ +/* + Post options menu behavior: the shared model behind the three-dots menu on + every post card. + + The menu's first item is a "See on block explorer" link to the post + transaction on bch.loping.net, opened in a new tab. This module owns the link + construction and the pure open/close/focus state transitions so the feed card + and the profile post card render identical behavior. + + It is written in plain CommonJS with no UI or DOM concerns so it can be used + by the browser components, by unit tests, and by the acceptance handlers. +*/ + +const BLOCK_EXPLORER_LABEL = 'See on block explorer' +const BLOCK_EXPLORER_TX_BASE = 'https://bch.loping.net/tx' + +// Block explorer URL for a post transaction, or '' without a txid. +function explorerTxUrl (txid) { + if (!txid) return '' + return `${BLOCK_EXPLORER_TX_BASE}/${txid}` +} + +// The ordered menu items for a post. The block explorer link is first. +function postOptionsItems (txid) { + return [ + { + id: 'block-explorer', + label: BLOCK_EXPLORER_LABEL, + href: explorerTxUrl(txid), + target: '_blank', + rel: 'noopener noreferrer' + } + ] +} + +// A fresh, closed menu state. +function initialPostOptionsState () { + return { open: false, focusedIndex: -1 } +} + +// Open the menu, leaving any current item focus intact. +function openPostOptions (state) { + return { ...state, open: true } +} + +// Close the menu and clear item focus. +function closePostOptions (state) { + return { ...state, open: false, focusedIndex: -1 } +} + +// Open a closed menu, close an open one. +function togglePostOptions (state) { + return state.open ? closePostOptions(state) : openPostOptions(state) +} + +// Move focus to the first menu item, if there is one. +function focusFirstPostOption (state, items = []) { + if (!items.length) return state + return { ...state, open: true, focusedIndex: 0 } +} + +// Escape closes the menu. +function handlePostOptionsEscape (state) { + return closePostOptions(state) +} + +// A click outside the menu closes it. +function handlePostOptionsOutsideClick (state) { + return closePostOptions(state) +} + +// True when a mousedown target is outside the menu container. A missing +// container (menu not mounted) or target is treated as inside so a detached +// menu cannot close itself. Pure so the component's effect stays a thin +// adapter and the decision is unit testable without a DOM. +function isOutsidePostOptions (container, target) { + return Boolean(container) && !container.contains(target) +} + +// Resolve a key press to the menu's next state. Returns null for keys that are +// not menu shortcuts. ArrowDown reveals the menu and focuses its first item; +// Escape closes it. preventDefault is true for keys that would otherwise scroll +// the page. +function postOptionsKeyCommand (key, items = []) { + if (key === 'Escape') { + return { transition: handlePostOptionsEscape, preventDefault: false } + } + + if (key === 'ArrowDown') { + return { + transition: (state) => focusFirstPostOption(state, items), + preventDefault: true + } + } + + return null +} + +module.exports = { + BLOCK_EXPLORER_LABEL, + BLOCK_EXPLORER_TX_BASE, + explorerTxUrl, + postOptionsItems, + initialPostOptionsState, + openPostOptions, + closePostOptions, + togglePostOptions, + focusFirstPostOption, + handlePostOptionsEscape, + handlePostOptionsOutsideClick, + isOutsidePostOptions, + postOptionsKeyCommand +} + +// mutate4javascript-manifest-begin +// {"version":1,"tested_at":"2026-09-16T16:53:14.617Z","module_hash":"9564c40bbc416ef70c9b53f4dcdd849182f85734b9e48f90521b400921d3c6b3","functions":[{"id":"func/explorerTxUrl","name":"explorerTxUrl","line":18,"end_line":21,"hash":"b0284558de13bf4b63d70227a688add4739e577a8908593f3ced4ff557be6a16"},{"id":"func/postOptionsItems","name":"postOptionsItems","line":24,"end_line":34,"hash":"271f7b7971ae5d011c7014444b67d4e3f7cd25b85474f9a556e8bf8a8acb2b35"},{"id":"func/initialPostOptionsState","name":"initialPostOptionsState","line":37,"end_line":39,"hash":"1b6ab74976ac9fb54520f85e9fab7e61109572783da055866dcf64e36fc857f9"},{"id":"func/openPostOptions","name":"openPostOptions","line":42,"end_line":44,"hash":"4e91430330da69f5829a931fe6df67d2c6b7aa791f8092e1152e791c4935c384"},{"id":"func/closePostOptions","name":"closePostOptions","line":47,"end_line":49,"hash":"fe56a32123f3f5241891e22b551b0abba708bf182b2e04a9c1301d15e12fa466"},{"id":"func/togglePostOptions","name":"togglePostOptions","line":52,"end_line":54,"hash":"3bcb1a0d78907b5e83920c5f5e0d3d4fd55e0d62691d3c71bfc84bd1b9d8adac"},{"id":"func/focusFirstPostOption","name":"focusFirstPostOption","line":57,"end_line":60,"hash":"c3f174e7232a704c72bc79d84c4e5ef81ad1f32012a9c215d83c5a4eb7bd3291"},{"id":"func/handlePostOptionsEscape","name":"handlePostOptionsEscape","line":63,"end_line":65,"hash":"028896c476d907d1ca26361c5958c5996ac33a5455fa90ed8a7d48eaed9d2301"},{"id":"func/handlePostOptionsOutsideClick","name":"handlePostOptionsOutsideClick","line":68,"end_line":70,"hash":"66e71edaea9e5a93f14c2cb24026a338c02be5451f1ecdb00145705cc41e3ed0"},{"id":"func/isOutsidePostOptions","name":"isOutsidePostOptions","line":76,"end_line":78,"hash":"a35084f2e010d5e4726c25cac48b78c7e10fe331caf2ca3cabc663607cbf1962"},{"id":"func/postOptionsKeyCommand","name":"postOptionsKeyCommand","line":84,"end_line":97,"hash":"625fb7f69df3baf377fcd9a0b0d3d2bd129b31090e51591552f4eaa056fc120e"}]} +// mutate4javascript-manifest-end diff --git a/psf-memo-client/test/property/post-options.property.test.js b/psf-memo-client/test/property/post-options.property.test.js new file mode 100644 index 0000000..0cced88 --- /dev/null +++ b/psf-memo-client/test/property/post-options.property.test.js @@ -0,0 +1,198 @@ +/* + Property tests for the post options menu. + + The unit tests probe post-options at a few fixed fixtures. These properties + pin down the service's and the component's invariants over broad random + inputs: + + - explorerTxUrl composes the block explorer base with the txid and returns + '' for every falsy input. + - postOptionsItems always offers the block explorer link first, with the + expected label, target, and rel, and an href equal to explorerTxUrl. + - Close transitions are idempotent and always clear focus; open preserves + focus; toggle is an involution on the open flag. + - focusFirstPostOption (ArrowDown) opens and focuses index 0 when items + exist, and is a no-op with no items. + - The key command maps only Escape and ArrowDown, never prevents the + default for anything else. + - The rendered component always shows the button, shows menu items only + when open, makes exactly the focused item tabbable, and is deterministic. +*/ + +'use strict' + +const test = require('node:test') +const React = require('react') +const ReactDOMServer = require('react-dom/server') +const { seededRandom, forAll, intGen } = require('./harness') +const PostOptions = require('../../src/services/post-options') +const PostOptionsMenu = require('../../src/components/post-feed/post-options-menu') + +const rng = seededRandom(20260916) + +const HEX = '0123456789abcdef' +const OTHER_KEYS = ['Enter', 'Tab', 'ArrowUp', 'ArrowLeft', 'Escape ', 'escape', ''] + +function randomTxid () { + const n = intGen(rng, 1, 64)() + let out = '' + for (let i = 0; i < n; i++) out += HEX[Math.floor(rng() * HEX.length)] + return out +} + +function randomState () { + return { open: rng() < 0.5, focusedIndex: intGen(rng, -1, 3)() } +} + +function render (props) { + return ReactDOMServer.renderToStaticMarkup( + React.createElement(PostOptionsMenu, props) + ) +} + +test('explorerTxUrl composes the base and the txid', async () => { + await forAll( + () => randomTxid(), + async (txid) => + PostOptions.explorerTxUrl(txid) === `${PostOptions.BLOCK_EXPLORER_TX_BASE}/${txid}`, + { label: 'explorerTxUrl composition', samples: 2000 } + ) +}) + +test('explorerTxUrl returns an empty string for every falsy input', async () => { + await forAll( + () => [undefined, null, '', 0, false][intGen(rng, 0, 4)()], + async (value) => PostOptions.explorerTxUrl(value) === '', + { label: 'explorerTxUrl falsy inputs', samples: 500 } + ) +}) + +test('postOptionsItems always puts the block explorer link first', async () => { + await forAll( + () => randomTxid(), + async (txid) => { + const items = PostOptions.postOptionsItems(txid) + if (items.length === 0) return false + const first = items[0] + return first.id === 'block-explorer' && + first.label === PostOptions.BLOCK_EXPLORER_LABEL && + first.target === '_blank' && + first.rel === 'noopener noreferrer' && + first.href === PostOptions.explorerTxUrl(txid) + }, + { label: 'postOptionsItems block explorer first', samples: 2000 } + ) +}) + +test('close transitions are idempotent and clear focus', async () => { + await forAll( + () => randomState(), + async (state) => { + const transitions = [ + PostOptions.closePostOptions, + PostOptions.handlePostOptionsEscape, + PostOptions.handlePostOptionsOutsideClick + ] + for (const transition of transitions) { + const once = transition(state) + const twice = transition(once) + if (once.open !== false || once.focusedIndex !== -1) return false + if (JSON.stringify(once) !== JSON.stringify(twice)) return false + } + return true + }, + { label: 'post options close idempotence', samples: 2000 } + ) +}) + +test('open preserves focus and toggle is an involution on open', async () => { + await forAll( + () => randomState(), + async (state) => { + const opened = PostOptions.openPostOptions(state) + if (opened.open !== true) return false + if (opened.focusedIndex !== state.focusedIndex) return false + const twice = PostOptions.togglePostOptions(PostOptions.togglePostOptions(state)) + return twice.open === state.open + }, + { label: 'post options open and toggle', samples: 2000 } + ) +}) + +test('focusFirstPostOption opens and focuses index 0 only when items exist', async () => { + await forAll( + () => randomState(), + async (state) => { + const items = PostOptions.postOptionsItems(randomTxid()) + const focused = PostOptions.focusFirstPostOption(state, items) + if (focused.open !== true || focused.focusedIndex !== 0) return false + const empty = PostOptions.focusFirstPostOption(state, []) + return JSON.stringify(empty) === JSON.stringify(state) + }, + { label: 'post options focus first', samples: 2000 } + ) +}) + +test('the key command maps only Escape and ArrowDown', async () => { + await forAll( + () => ({ key: OTHER_KEYS[intGen(rng, 0, OTHER_KEYS.length - 1)()], txid: randomTxid() }), + async ({ key, txid }) => { + const items = PostOptions.postOptionsItems(txid) + if (PostOptions.postOptionsKeyCommand(key, items) !== null) return false + + const escape = PostOptions.postOptionsKeyCommand('Escape', items) + if (!escape || escape.preventDefault !== false) return false + if (escape.transition(PostOptions.initialPostOptionsState()).open !== false) return false + + const arrowDown = PostOptions.postOptionsKeyCommand('ArrowDown', items) + if (!arrowDown || arrowDown.preventDefault !== true) return false + const next = arrowDown.transition(PostOptions.initialPostOptionsState()) + return next.open === true && next.focusedIndex === 0 + }, + { label: 'post options key command', samples: 1000 } + ) +}) + +test('the rendered menu shows the button always and items only when open', async () => { + await forAll( + () => ({ txid: randomTxid(), open: rng() < 0.5 }), + async ({ txid, open }) => { + const html = render({ txid, initialOpen: open }) + if (!html.includes('aria-label="Post options"')) return false + if (!html.includes(`aria-expanded="${open ? 'true' : 'false'}"`)) return false + if (html.includes(PostOptions.BLOCK_EXPLORER_LABEL) !== open) return false + if (open && !html.includes(`href="${PostOptions.explorerTxUrl(txid)}"`)) return false + if (open && !html.includes('target="_blank"')) return false + return true + }, + { label: 'post options render open and closed', samples: 1000 } + ) +}) + +test('exactly the focused menu item is tabbable', async () => { + await forAll( + () => ({ txid: randomTxid(), focusedIndex: intGen(rng, -1, 0)() }), + async ({ txid, focusedIndex }) => { + const html = render({ txid, initialOpen: true, initialFocusedIndex: focusedIndex }) + const tabbable = (html.match(/tabindex="0"/g) || []).length + return tabbable === (focusedIndex === 0 ? 1 : 0) + }, + { label: 'post options tabbable item', samples: 500 } + ) +}) + +test('rendering the same props twice yields the same markup', async () => { + await forAll( + () => ({ + txid: randomTxid(), + initialOpen: rng() < 0.5, + initialFocusedIndex: intGen(rng, -1, 1)() + }), + async (props) => { + const first = render(props) + const second = render(props) + return first === second + }, + { label: 'post options render determinism', samples: 500 } + ) +}) diff --git a/psf-memo-client/test/unit/post-options.test.js b/psf-memo-client/test/unit/post-options.test.js new file mode 100644 index 0000000..e64b64c --- /dev/null +++ b/psf-memo-client/test/unit/post-options.test.js @@ -0,0 +1,182 @@ +/* + Unit tests for the post options menu. + + Every post card shows a three-dots "Post options" button whose menu's first + item is "See on block explorer", a link to the post transaction on + bch.loping.net that opens in a new tab. The menu opens on the button, closes + on the button again, an outside click, or Escape, and ArrowDown moves focus + to the first item. + + The pure service owns the link construction and the open/close/focus state + transitions; the component renders that state to markup. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const React = require('react') +const ReactDOMServer = require('react-dom/server') +const PostOptions = require('../../src/services/post-options') +const PostOptionsMenu = require('../../src/components/post-feed/post-options-menu') + +const TXID = 'c96a46c8b55657fe125115e3ddf5ad30ad587bb41baa952cb8d9be9334161875' +const EXPLORER_URL = `https://bch.loping.net/tx/${TXID}` + +function renderMenu (props = {}) { + return ReactDOMServer.renderToStaticMarkup( + React.createElement(PostOptionsMenu, { txid: TXID, ...props }) + ) +} + +test('explorerTxUrl builds the bch.loping.net transaction link', () => { + assert.equal(PostOptions.explorerTxUrl(TXID), EXPLORER_URL) +}) + +test('explorerTxUrl returns an empty string without a txid', () => { + assert.equal(PostOptions.explorerTxUrl(''), '') + assert.equal(PostOptions.explorerTxUrl(null), '') + assert.equal(PostOptions.explorerTxUrl(undefined), '') +}) + +test('postOptionsItems puts the block explorer link first', () => { + const items = PostOptions.postOptionsItems(TXID) + + assert.equal(items[0].label, 'See on block explorer') + assert.equal(items[0].href, EXPLORER_URL) + assert.equal(items[0].target, '_blank') + assert.equal(items[0].rel, 'noopener noreferrer') +}) + +test('the menu starts hidden', () => { + const state = PostOptions.initialPostOptionsState() + + assert.equal(state.open, false) + assert.equal(state.focusedIndex, -1) +}) + +test('toggling the menu opens it, then closes it again', () => { + let state = PostOptions.initialPostOptionsState() + + state = PostOptions.togglePostOptions(state) + assert.equal(state.open, true) + + state = PostOptions.togglePostOptions(state) + assert.equal(state.open, false) +}) + +test('closing the menu also clears item focus', () => { + const open = PostOptions.openPostOptions(PostOptions.initialPostOptionsState()) + const focused = PostOptions.focusFirstPostOption( + open, + PostOptions.postOptionsItems(TXID) + ) + const closed = PostOptions.closePostOptions(focused) + + assert.equal(closed.open, false) + assert.equal(closed.focusedIndex, -1) +}) + +test('Escape and an outside click both close the menu', () => { + const open = PostOptions.openPostOptions(PostOptions.initialPostOptionsState()) + + assert.equal(PostOptions.handlePostOptionsEscape(open).open, false) + assert.equal(PostOptions.handlePostOptionsOutsideClick(open).open, false) +}) + +test('isOutsidePostOptions is true only for a click outside a mounted menu', () => { + const inside = { contains: () => true } + const outside = { contains: () => false } + + assert.equal(PostOptions.isOutsidePostOptions(inside, {}), false) + assert.equal(PostOptions.isOutsidePostOptions(outside, {}), true) + assert.equal(PostOptions.isOutsidePostOptions(null, {}), false) + assert.equal(PostOptions.isOutsidePostOptions(undefined, {}), false) +}) + +test('focusing the first item selects index 0 when items exist', () => { + const open = PostOptions.openPostOptions(PostOptions.initialPostOptionsState()) + const focused = PostOptions.focusFirstPostOption( + open, + PostOptions.postOptionsItems(TXID) + ) + + assert.equal(focused.focusedIndex, 0) +}) + +test('focusing the first item is a no-op with no items', () => { + const open = PostOptions.openPostOptions(PostOptions.initialPostOptionsState()) + const focused = PostOptions.focusFirstPostOption(open, []) + + assert.equal(focused.focusedIndex, -1) +}) + +test('the Escape key command closes the menu without preventing default', () => { + const command = PostOptions.postOptionsKeyCommand( + 'Escape', + PostOptions.postOptionsItems(TXID) + ) + + assert.equal(command.preventDefault, false) + const next = command.transition( + PostOptions.openPostOptions(PostOptions.initialPostOptionsState()) + ) + assert.equal(next.open, false) + assert.equal(next.focusedIndex, -1) +}) + +test('the ArrowDown key command focuses the first item and prevents default', () => { + const command = PostOptions.postOptionsKeyCommand( + 'ArrowDown', + PostOptions.postOptionsItems(TXID) + ) + + assert.equal(command.preventDefault, true) + const next = command.transition(PostOptions.initialPostOptionsState()) + assert.equal(next.open, true) + assert.equal(next.focusedIndex, 0) +}) + +test('the ArrowDown key command keeps the menu closed when there are no items', () => { + const command = PostOptions.postOptionsKeyCommand('ArrowDown', []) + const next = command.transition(PostOptions.initialPostOptionsState()) + + assert.equal(next.open, false) + assert.equal(next.focusedIndex, -1) +}) + +test('other keys have no menu command', () => { + assert.equal(PostOptions.postOptionsKeyCommand('Enter', PostOptions.postOptionsItems(TXID)), null) + assert.equal(PostOptions.postOptionsKeyCommand('Tab', []), null) +}) + +test('the closed menu renders the post options button but no items', () => { + const html = renderMenu() + + assert.match(html, /aria-label="Post options"/) + assert.match(html, /aria-haspopup="menu"/) + assert.match(html, /aria-expanded="false"/) + assert.doesNotMatch(html, /See on block explorer/) +}) + +test('the open menu renders the block explorer link that opens in a new tab', () => { + const html = renderMenu({ initialOpen: true }) + + assert.match(html, /aria-expanded="true"/) + assert.match(html, /See on block explorer/) + assert.match(html, new RegExp(`href="${EXPLORER_URL.replace(/[.]/g, '\\.')}"`)) + assert.match(html, /target="_blank"/) + assert.match(html, /rel="noopener noreferrer"/) +}) + +test('the focused first item is tabbable and the others are not', () => { + const html = renderMenu({ initialOpen: true, initialFocusedIndex: 0 }) + + assert.match(html, /tabindex="0"/) +}) + +test('an open menu with no focused index makes no item tabbable', () => { + const html = renderMenu({ initialOpen: true }) + + assert.doesNotMatch(html, /tabindex="0"/) +})