mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Merge like-result-modal from swarmforge-architect
By specifier.
This commit is contained in:
@@ -18,6 +18,21 @@ distinct from per-task verification results, which live in
|
||||
|
||||
## Tooling behavior / runtime
|
||||
|
||||
- **Pure modules can legitimately report 0 mutation sites.** `mutate4javascript`
|
||||
only targets arithmetic, comparison, equality, boolean, logical, and `0<->1`
|
||||
constant sites. A module built from `!` guards, ternaries, and template
|
||||
literals (e.g. `block-explorer.js`, `like-result.js`) scans as `Total mutation
|
||||
sites: 0` with `Killed: 0, Survived: 0`. Run `--scan` to confirm the zero is
|
||||
structural and not a skipped/under-selected run before treating it as a pass.
|
||||
|
||||
- **Run `dry4javascript` scoped to the changed files/dirs, not the whole client.**
|
||||
A broad `src test acceptance` run reports hundreds of pre-existing duplicate
|
||||
blocks (475 in the client on 2026-09-16) — mostly `acceptance/lib/handlers.js`
|
||||
step-handler boilerplate and repeated older-suite test setup — which buries the
|
||||
one or two task-local candidates. Scope the run to the changed production
|
||||
files, tests, and adapters; the broad run is only a noise floor, consistent
|
||||
with prior reviews.
|
||||
|
||||
- **Bare `dry4javascript` runs the full test suite (~1m50s).** It is a DRY
|
||||
analysis that invokes tests, so running it with no arguments is a slow
|
||||
full-suite run, not a fast readiness probe. Never use it as a startup smoke
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
# like-result-modal — Architect Review
|
||||
|
||||
Task: `like-result-modal`
|
||||
Component: `psf-memo-client`
|
||||
Base: `3486da3` (last merged architect review); inbound refactorer commit `e978516`
|
||||
|
||||
## What was reviewed
|
||||
|
||||
Inbound refactorer batch (priority 50), merged onto `swarmforge-architect` by
|
||||
fast-forwarding `3486da3` -> `e978516`. The linear chain reviewed:
|
||||
|
||||
- **`efe7a6c`** — specifier: *Record post-options-menu completion in backlog and briefing*.
|
||||
- **`3af0d42`** — specifier: *Add like broadcast result specification*. Adds
|
||||
`psf-memo-client/specs/like-broadcast-result.feature` (3 scenario outlines).
|
||||
- **`a0040f9`** — coder: *Implement like broadcast result modal*. Keeps the
|
||||
like/tip modal open after a successful like and shows the success message, the
|
||||
like txid, and a block-explorer link. Adds the pure `LikeTipPage` result state,
|
||||
the presentational `like-result.js`, the acceptance render adapter, step
|
||||
handlers, and unit tests.
|
||||
- **`e978516`** — refactorer: *Refactor like result modal and share the block
|
||||
explorer link*. Extracts `src/services/block-explorer.js` as the single source
|
||||
of the `bch.loping.net/tx` URL shared by the New Post result modal, the post
|
||||
options menu, and the like/tip result; DRYs the new acceptance steps behind
|
||||
`renderLikeBroadcastResult`; adds property tests.
|
||||
|
||||
**Architect review commit: `e071881ea0`** — the `mutate4javascript` footer
|
||||
manifests, the soft `gherkin-mutator` acceptance-mutation manifest stamp, and the
|
||||
hardening changes below. The summary and verification record are committed on
|
||||
top, so `git diff e071881ea0 HEAD` touches only `docs/`. The record's `git_sha`
|
||||
is `e071881ea0`, the commit that contains the verified source state.
|
||||
|
||||
## Architectural findings and fixes applied
|
||||
|
||||
The refactorer's structure is sound: a framework-free controller, a
|
||||
server-renderable presentational component, a pure shared URL module, and a
|
||||
separate acceptance render adapter. The hardening work was mutation- and
|
||||
duplication-driven.
|
||||
|
||||
1. **UI/Core separation.** `src/services/block-explorer.js` is a pure leaf
|
||||
(`if (!txid) return ''` + a template literal) with no React, DOM, or IO.
|
||||
`LikeTipPage` owns the broadcast-result state machine and is exercised with no
|
||||
browser; `LikeResult` only maps props to markup and is reached by the
|
||||
acceptance adapter through `acceptance/lib/render-like-result.js`, which
|
||||
server-renders the actual component.
|
||||
2. **Dependency rule.** `new-post.js`, `post-options.js`, and `like-tip-page.js`
|
||||
depend inward on `block-explorer.js`; nothing in the pure modules reaches out
|
||||
to React, the router, or the wallet. No framework or persistence structure
|
||||
leaks across the boundary.
|
||||
3. **Information hiding / DRY.** The block-explorer base URL and link shape now
|
||||
have exactly one definition. The old `EXPLORER_TX_BASE` literals in
|
||||
`new-post.js`, `post-options.js`, and `like-tip-page.js` are gone; the
|
||||
`EXPLORER_TX_BASE`/`explorerUrl`/`explorerTxUrl` names remain as thin aliases
|
||||
for existing acceptance/test callers.
|
||||
4. **Mutation survivors (like-tip-page).** Initial run: **13 killed, 8 survived,
|
||||
0 uncovered** across 21 sites. Every survivor was a missing assertion, not a
|
||||
design fault:
|
||||
- the `false` initial `tipping`/`modalOpen`, and the `deps.postTxid`/
|
||||
`deps.authorAddress` constructor seeds, were never asserted;
|
||||
- `open`'s `<` dust comparison had no boundary case (balance exactly at 3000);
|
||||
- the `open` success result's `true` was unchecked;
|
||||
- `_parseTip`'s second `||` (null/undefined) was uncovered;
|
||||
- `_handleSubmitFailure`'s `err.message || String(err)` survived because a
|
||||
*broadcast* failure is re-derived by the superclass, so the value is only
|
||||
observable on a *validation* failure.
|
||||
Added unit coverage for each; re-run: **21 killed, 0 survived, 0 uncovered**.
|
||||
5. **Mutation survivor (new-post).** The `NewPostPage` constructor's
|
||||
`this.showResultModal = false` (line 32) was a pre-existing survivor. Added an
|
||||
initial-state assertion; re-run: **10 killed, 0 survived, 0 uncovered**.
|
||||
6. **Test duplication.** The first DRY pass flagged two structurally identical
|
||||
test pairs in `like-tip-page.test.js`. Extracted a `submitLike()` helper and a
|
||||
`balance` option on `makePage()`; the remaining pair (dismiss vs. reopen) is
|
||||
semantically distinct and is left as structural test boilerplate.
|
||||
7. **Result-modal controller duplication (documented, not changed).**
|
||||
`LikeTipPage` mirrors the `showResultModal`/`lastResult`/`submit`/
|
||||
`dismissResult` shape of `NewPostPage`, with a different show/dismiss policy.
|
||||
A shared result-modal controller would touch both controllers, their unit
|
||||
tests, and acceptance handlers; per `docs/architect-process-notes.md` that is
|
||||
a broad cross-module refactor beyond a single review, so it is recorded as a
|
||||
follow-up candidate rather than folded into this task. Likewise, the React
|
||||
`like-tip-modal.js` keeps its own transient `showResult`/`resultTxid` state;
|
||||
it is the environmentally unsuitable shell that node unit and acceptance
|
||||
mutation deliberately do not target, matching the existing component pattern.
|
||||
|
||||
## Verification results
|
||||
|
||||
### Language mutation (`mutate4javascript`, `--mutate-all`, `--max-workers 8`)
|
||||
|
||||
| File | Sites | Killed | Survived | Uncovered |
|
||||
|------|------:|-------:|---------:|----------:|
|
||||
| `src/services/like-tip-page.js` | 21 | 21 | 0 | 0 |
|
||||
| `src/services/new-post.js` | 10 | 10 | 0 | 0 |
|
||||
| `src/services/post-options.js` | 10 | 10 | 0 | 0 |
|
||||
| `src/services/block-explorer.js` | 0 | 0 | 0 | 0 |
|
||||
| `src/components/post-feed/like-result.js` | 0 | 0 | 0 | 0 |
|
||||
|
||||
`block-explorer.js` and `like-result.js` contain only `!`, a ternary, and a
|
||||
template literal — constructs `mutate4javascript` does not target — so `--scan`
|
||||
reports 0 sites; this is structural, not a skipped run. The two JSX/ESM
|
||||
components (`like-tip-modal.js`, `post-feed-item.js`) are outside the node unit
|
||||
test boundary and are covered end-to-end by acceptance.
|
||||
|
||||
### DRY (`dry4javascript`)
|
||||
|
||||
Focused run over the changed source, tests, and adapters: the only task-local
|
||||
duplicate left is the semantically distinct dismiss/reopen test pair noted above.
|
||||
A broad run over `src test acceptance` reports 475 blocks, all pre-existing
|
||||
`acceptance/lib/handlers.js` step-handler boilerplate and repeated test setup
|
||||
across older suites — none involves the new production modules.
|
||||
|
||||
### CRAP / cyclomatic complexity (`crap4javascript`)
|
||||
|
||||
All changed functions at or below the 8.0 threshold with **100% coverage**, e.g.
|
||||
`LikeTipPage._parseTip` (CC 5, CRAP 5.0), `LikeTipPage.open` (CC 4, CRAP 4.0),
|
||||
`LikeTipPage.getBroadcastMessage` (CC 3, CRAP 3.0), `NewPostPage.submit`/
|
||||
`dismissResult` (CC 3, CRAP 3.0), `blockExplorerTxUrl` (CC 2, CRAP 2.0),
|
||||
`LikeResult` (CC 2, CRAP 2.0).
|
||||
|
||||
### Soft Gherkin acceptance mutation (`gherkin-mutator --level soft`)
|
||||
|
||||
`like-broadcast-result.feature`: **7 executed, 4 killed, 3 survived, 0 errors**.
|
||||
The three survivors are consistent-value intrinsic equivalents — each mutated
|
||||
example value is used consistently on both the setup and assertion sides:
|
||||
|
||||
- `tip: 600 -> 601` and `tip: 25000 -> 25007` (Scenario 2): the tip is entered
|
||||
and then asserted against the same `<tip>` parameter.
|
||||
- Scenario 3 `liked_txid` with an injected `x`: Scenario 3's purpose is the
|
||||
dismiss-closes-modal behavior; it reuses `<liked_txid>` on the setup and click
|
||||
steps but makes no txid-validity or broadcast assertion (Scenarios 1 and 2,
|
||||
which do assert the broadcast, killed the same class of mutation).
|
||||
|
||||
The tool stamped Scenario 0 (all mutations killed) and left Scenarios 1–2
|
||||
unstamped, as designed.
|
||||
|
||||
### Suite status
|
||||
|
||||
`swarmforge/scripts/verify.sh client --record
|
||||
docs/reviews/like-result-modal-verification.json --task like-result-modal`
|
||||
-> **pass (5/5)**:
|
||||
unit **387 pass / 0 fail**, property **75 pass / 0 fail**, acceptance **all 29
|
||||
suites passed**, lint **ok**, build **ok**. Record `git_sha` = `e071881ea0`.
|
||||
|
||||
## Handoffs sent
|
||||
|
||||
- End-of-chain `git_handoff` to the specifier (task `like-result-modal`) with the
|
||||
review commit so it can merge `swarmforge-architect` into `master`.
|
||||
- No coder/refactorer handoff: the review is test hardening plus manifest refresh
|
||||
with no follow-up work for those roles.
|
||||
|
||||
By architect.
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"task": "like-result-modal",
|
||||
"component": "psf-memo-client",
|
||||
"git_sha": "e071881ea06f9a7951352e5242319d21f7ba7997",
|
||||
"branch": "swarmforge-architect",
|
||||
"timestamp": "2026-09-16T19:31:39.772Z",
|
||||
"commands": [
|
||||
{
|
||||
"name": "unit",
|
||||
"command": "npm test",
|
||||
"exit": 0,
|
||||
"duration_ms": 8374,
|
||||
"summary": "387 pass / 0 fail"
|
||||
},
|
||||
{
|
||||
"name": "property",
|
||||
"command": "npm run test:property",
|
||||
"exit": 0,
|
||||
"duration_ms": 9402,
|
||||
"summary": "75 pass / 0 fail"
|
||||
},
|
||||
{
|
||||
"name": "acceptance",
|
||||
"command": "npm run test:acceptance",
|
||||
"exit": 0,
|
||||
"duration_ms": 13394,
|
||||
"summary": "all 29 acceptance suites passed"
|
||||
},
|
||||
{
|
||||
"name": "lint",
|
||||
"command": "npm run lint",
|
||||
"exit": 0,
|
||||
"duration_ms": 2266,
|
||||
"summary": "ok"
|
||||
},
|
||||
{
|
||||
"name": "build",
|
||||
"command": "npm run build",
|
||||
"exit": 0,
|
||||
"duration_ms": 66200,
|
||||
"summary": "ok"
|
||||
}
|
||||
],
|
||||
"result": "pass"
|
||||
}
|
||||
@@ -55,6 +55,7 @@ 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 { renderLikeResult } = require('./render-like-result')
|
||||
const PostOptions = require('../../src/services/post-options')
|
||||
const { YOUTUBE_EMBED_BASE_URL } = require('../../src/services/youtube-embed')
|
||||
|
||||
@@ -1493,6 +1494,58 @@ const handlers = [
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'like/tip modal remains open',
|
||||
pattern: /^the like\/tip modal remains open$/,
|
||||
run (m, example, world) {
|
||||
if (!world.likeTipPage.modalOpen) {
|
||||
throw new Error('Expected like/tip modal to remain open.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'like/tip modal shows a broadcast success message',
|
||||
pattern: /^the like\/tip modal shows a broadcast success message$/,
|
||||
run (m, example, world) {
|
||||
const { message, html } = renderLikeBroadcastResult(world)
|
||||
if (!html.includes(message)) {
|
||||
throw new Error(`The rendered like result does not show the message "${message}".`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'like/tip modal shows the like transaction id',
|
||||
pattern: /^the like\/tip modal shows the like transaction id$/,
|
||||
run (m, example, world) {
|
||||
const { txid, html } = renderLikeBroadcastResult(world)
|
||||
if (!html.includes(txid)) {
|
||||
throw new Error(`The rendered like result does not show the transaction id ${txid}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'like/tip modal shows a block explorer link',
|
||||
pattern: /^the like\/tip modal shows a link to the block explorer for the like transaction$/,
|
||||
run (m, example, world) {
|
||||
const { url, html } = renderLikeBroadcastResult(world)
|
||||
if (!url.startsWith('https://bch.loping.net/tx/')) {
|
||||
throw new Error(`Expected a bch.loping.net explorer link, got "${url}".`)
|
||||
}
|
||||
if (!html.includes(`href="${url}"`)) {
|
||||
throw new Error(`The rendered like result does not link to ${url}.`)
|
||||
}
|
||||
if (!html.includes('target="_blank"')) {
|
||||
throw new Error('The rendered like explorer link does not open in a new tab.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'dismiss like result',
|
||||
pattern: /^I dismiss the like result$/,
|
||||
run (m, example, world) {
|
||||
world.likeTipPage.dismissResult()
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'API serves post with explicit address and like count',
|
||||
pattern: /^the psf-memo-db API serves a post with txid (.+) authored by the address (.+) with a like count of (.+)$/,
|
||||
@@ -3415,6 +3468,26 @@ function togglePostOptionsMenu (world, txid) {
|
||||
world.activeMenuTxid = txid
|
||||
}
|
||||
|
||||
// Require a successful like broadcast result and render it to static HTML for
|
||||
// the like/tip acceptance assertions. The caller inspects the returned fields.
|
||||
function renderLikeBroadcastResult (world) {
|
||||
const page = world.likeTipPage
|
||||
if (!page.showResultModal || !page.lastResult || !page.lastResult.ok) {
|
||||
throw new Error('Expected a successful like broadcast result.')
|
||||
}
|
||||
const txid = page.lastResult.txid
|
||||
if (!txid) {
|
||||
throw new Error('Expected the like result to include a transaction id.')
|
||||
}
|
||||
const message = page.getBroadcastMessage()
|
||||
if (!message) {
|
||||
throw new Error('Expected a like broadcast success message.')
|
||||
}
|
||||
const url = page.explorerUrl(txid)
|
||||
const html = renderLikeResult({ txid, message, explorerUrl: url })
|
||||
return { txid, message, url, html }
|
||||
}
|
||||
|
||||
// The posts currently rendered by the page the scenario has opened.
|
||||
function postsOnCurrentPage (world) {
|
||||
const path = world.currentPath || ''
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Acceptance rendering adapter for the like broadcast result.
|
||||
|
||||
Renders the same LikeResult component the browser uses to a static HTML
|
||||
string, so acceptance assertions can inspect the success message, the like
|
||||
transaction id, and the block explorer link without running a browser.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const React = require('react')
|
||||
const ReactDOMServer = require('react-dom/server')
|
||||
const LikeResult = require('../../src/components/post-feed/like-result')
|
||||
|
||||
function renderLikeResult ({ txid = '', message = '', explorerUrl = '' } = {}) {
|
||||
const element = React.createElement(LikeResult, { txid, message, explorerUrl })
|
||||
return ReactDOMServer.renderToStaticMarkup(element)
|
||||
}
|
||||
|
||||
module.exports = { renderLikeResult }
|
||||
@@ -1,3 +1,7 @@
|
||||
# acceptance-mutation-manifest-begin
|
||||
# {"version":1,"tested_at":"2026-09-16T19:27:51.931950466Z","feature_name":"Like Broadcast Result","feature_path":"/home/trout/work/psf-memo/.worktrees/architect/psf-memo-client/specs/like-broadcast-result.feature","background_hash":"1714896143ae0425b1c9938c1bc926ef61183ee3e842aa76d8bfd0ec751fcc9c","implementation_hash":"unknown","scenarios":[{"index":0,"name":"Like Broadcast Result - 1 a successful like keeps the modal open and shows the broadcast result","scenario_hash":"315b01b6a9c59504c9ecf91ec0229a957c7490387f69f246b33f899dd18a896f","mutation_count":2,"result":{"Total":2,"Killed":2,"Survived":0,"Errors":0},"tested_at":"2026-09-16T19:27:51.931950466Z"}]}
|
||||
# acceptance-mutation-manifest-end
|
||||
|
||||
# Scenarios: Like Broadcast Result - 1, Like Broadcast Result - 2, Like Broadcast Result - 3
|
||||
#
|
||||
# After a like is broadcast from the like/tip modal, the modal no longer closes
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
Broadcast result for a like/tip submission.
|
||||
|
||||
Shown after a like is broadcast: the success message, the like transaction
|
||||
id, and a link to that transaction on the block explorer. The link opens in
|
||||
a new tab.
|
||||
|
||||
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')
|
||||
|
||||
function LikeResult ({ txid = '', message = '', explorerUrl = '' }) {
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ className: 'like-result' },
|
||||
React.createElement('p', { className: 'like-result-message' }, message),
|
||||
txid
|
||||
? React.createElement(
|
||||
'p',
|
||||
{ className: 'like-result-txid mb-0' },
|
||||
'Transaction ID: ',
|
||||
React.createElement(
|
||||
'a',
|
||||
{
|
||||
href: explorerUrl,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
style: { wordBreak: 'break-all' }
|
||||
},
|
||||
txid
|
||||
)
|
||||
)
|
||||
: null
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = LikeResult
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-09-16T19:24:39.237Z","module_hash":"53beb37c8b7aaa5c0291ff89be8444acd53240d70817b691a8eeb7def5d072f2","functions":[{"id":"func/LikeResult","name":"LikeResult","line":15,"end_line":38,"hash":"a9094a5a6f71e1142eab902750c33dded00411ae9627158589831aa3567c96a8"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
@@ -12,6 +12,7 @@ import { Modal, Form, Button } from 'react-bootstrap'
|
||||
|
||||
import MemoLike from '../../services/memo-like'
|
||||
import LikeTipPage from '../../services/like-tip-page'
|
||||
import LikeResult from './like-result'
|
||||
import { getDisplayName } from './post-display'
|
||||
import './post-feed.css'
|
||||
|
||||
@@ -24,6 +25,8 @@ function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess })
|
||||
const [tip, setTip] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [resultTxid, setResultTxid] = useState('')
|
||||
const [showResult, setShowResult] = useState(false)
|
||||
|
||||
const displayName = post ? getDisplayName(post.addr, profiles) : ''
|
||||
|
||||
@@ -33,12 +36,16 @@ function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess })
|
||||
setTip('')
|
||||
setError('')
|
||||
setSubmitting(false)
|
||||
setResultTxid('')
|
||||
setShowResult(false)
|
||||
return
|
||||
}
|
||||
|
||||
setTip('')
|
||||
setError('')
|
||||
setSubmitting(false)
|
||||
setResultTxid('')
|
||||
setShowResult(false)
|
||||
|
||||
let cancelled = false
|
||||
|
||||
@@ -82,6 +89,8 @@ function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess })
|
||||
const result = await page.submit()
|
||||
if (result.ok) {
|
||||
setTip('')
|
||||
setResultTxid(result.txid)
|
||||
setShowResult(true)
|
||||
if (typeof onSuccess === 'function') {
|
||||
onSuccess()
|
||||
}
|
||||
@@ -95,12 +104,18 @@ function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess })
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel () {
|
||||
function handleDismiss () {
|
||||
setTip('')
|
||||
setError('')
|
||||
setResultTxid('')
|
||||
setShowResult(false)
|
||||
onHide()
|
||||
}
|
||||
|
||||
function handleCancel () {
|
||||
handleDismiss()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal show={show} onHide={handleCancel} centered>
|
||||
<Modal.Header closeButton>
|
||||
@@ -108,47 +123,69 @@ function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess })
|
||||
</Modal.Header>
|
||||
|
||||
<Modal.Body>
|
||||
{post && (
|
||||
{post && !showResult && (
|
||||
<p className='like-tip-modal-target'>
|
||||
Like the post by <strong>{displayName}</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Form onSubmit={(e) => { e.preventDefault(); handleSubmit() }}>
|
||||
<Form.Group controlId='like-tip-amount' className='mb-3'>
|
||||
<Form.Label>Tip (satoshis, optional)</Form.Label>
|
||||
<Form.Control
|
||||
type='number'
|
||||
min='0'
|
||||
step='1'
|
||||
placeholder='0'
|
||||
value={tip}
|
||||
onChange={(e) => {
|
||||
setTip(e.target.value)
|
||||
// Clear a previous validation error so the user can retry.
|
||||
setError('')
|
||||
}}
|
||||
disabled={submitting}
|
||||
{showResult
|
||||
? (
|
||||
<LikeResult
|
||||
txid={resultTxid}
|
||||
message={LikeTipPage.SUCCESS_MESSAGE}
|
||||
explorerUrl={LikeTipPage.explorerUrl(resultTxid)}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Form onSubmit={(e) => { e.preventDefault(); handleSubmit() }}>
|
||||
<Form.Group controlId='like-tip-amount' className='mb-3'>
|
||||
<Form.Label>Tip (satoshis, optional)</Form.Label>
|
||||
<Form.Control
|
||||
type='number'
|
||||
min='0'
|
||||
step='1'
|
||||
placeholder='0'
|
||||
value={tip}
|
||||
onChange={(e) => {
|
||||
setTip(e.target.value)
|
||||
// Clear a previous validation error so the user can retry.
|
||||
setError('')
|
||||
}}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
|
||||
{error && (
|
||||
<p className='like-tip-modal-error text-danger'>{error}</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className='like-tip-modal-error text-danger'>{error}</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button variant='secondary' onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !post || !wallet}
|
||||
>
|
||||
{submitting ? 'Liking...' : 'Like'}
|
||||
</Button>
|
||||
{showResult
|
||||
? (
|
||||
<Button variant='primary' onClick={handleDismiss}>
|
||||
Close
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Button variant='secondary' onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !post || !wallet}
|
||||
>
|
||||
{submitting ? 'Liking...' : 'Like'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
@@ -77,9 +77,10 @@ function PostFeedItem ({
|
||||
}
|
||||
|
||||
const handleLikeSuccess = () => {
|
||||
// Reflect the like immediately. The modal stays open to show the
|
||||
// broadcast result until the user dismisses it.
|
||||
setLiked(true)
|
||||
setLikeCount((count) => count + 1)
|
||||
setShowLikeModal(false)
|
||||
}
|
||||
|
||||
const handleLikeModalHide = () => {
|
||||
|
||||
@@ -681,6 +681,18 @@
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.like-result {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.like-result-message {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.like-result-txid {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.posts-feed-item-youtube {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
Block explorer link for a Bitcoin Cash transaction.
|
||||
|
||||
Single source for the bch.loping.net transaction URL shared by the New Post
|
||||
result modal, the post options menu, and the like/tip broadcast result, so
|
||||
the base URL and link shape cannot drift between features.
|
||||
*/
|
||||
|
||||
const BLOCK_EXPLORER_TX_BASE = 'https://bch.loping.net/tx'
|
||||
|
||||
// Block explorer URL for a transaction, or '' without a txid.
|
||||
function blockExplorerTxUrl (txid) {
|
||||
if (!txid) return ''
|
||||
return `${BLOCK_EXPLORER_TX_BASE}/${txid}`
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BLOCK_EXPLORER_TX_BASE,
|
||||
blockExplorerTxUrl
|
||||
}
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-09-16T19:07:27.216Z","module_hash":"941a53501bc90a2ae36e1c0dc3890fd24add133e651c868a1a8f4f6a31035cb7","functions":[{"id":"func/blockExplorerTxUrl","name":"blockExplorerTxUrl","line":12,"end_line":15,"hash":"a8d84886234e25a946eb3363e23583b621851a2778990aa57df7d32fb8ed949a"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
@@ -14,6 +14,9 @@
|
||||
*/
|
||||
|
||||
const PageController = require('./page-controller')
|
||||
const { BLOCK_EXPLORER_TX_BASE, blockExplorerTxUrl } = require('./block-explorer')
|
||||
|
||||
const SUCCESS_MESSAGE = 'Your like was broadcast to the Bitcoin Cash network.'
|
||||
|
||||
class LikeTipPage extends PageController {
|
||||
constructor (deps = {}) {
|
||||
@@ -25,6 +28,10 @@ class LikeTipPage extends PageController {
|
||||
this.authorAddress = deps.authorAddress || ''
|
||||
this.successPath = null
|
||||
this.validationCodes = ['like_validation', 'like_dust', 'like_maximum', 'like_balance', 'like_empty_balance']
|
||||
// After a broadcast, the like/tip modal stays open and shows the result
|
||||
// until the user dismisses it.
|
||||
this.showResultModal = false
|
||||
this.lastResult = null
|
||||
}
|
||||
|
||||
// Open the like/tip modal for a post and check that the wallet has enough
|
||||
@@ -33,6 +40,8 @@ class LikeTipPage extends PageController {
|
||||
this.postTxid = postTxid
|
||||
this.authorAddress = authorAddress
|
||||
this.modalOpen = true
|
||||
this.showResultModal = false
|
||||
this.lastResult = null
|
||||
this.submitError = null
|
||||
this.broadcastError = null
|
||||
|
||||
@@ -75,6 +84,38 @@ class LikeTipPage extends PageController {
|
||||
this.tipping = value
|
||||
}
|
||||
|
||||
// Submit the like. A successful broadcast leaves the modal open and shows
|
||||
// the broadcast result instead of closing or navigating. Validation and
|
||||
// broadcast failures stay on the form.
|
||||
async submit () {
|
||||
this.showResultModal = false
|
||||
this.lastResult = null
|
||||
const result = await super.submit()
|
||||
this.lastResult = result
|
||||
if (result.ok) {
|
||||
this.showResultModal = true
|
||||
this.modalOpen = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// The broadcast success message shown while the result is visible.
|
||||
getBroadcastMessage () {
|
||||
if (!this.lastResult || !this.lastResult.ok) return ''
|
||||
return SUCCESS_MESSAGE
|
||||
}
|
||||
|
||||
// Block explorer URL for a broadcast like transaction.
|
||||
explorerUrl (txid) {
|
||||
return LikeTipPage.explorerUrl(txid)
|
||||
}
|
||||
|
||||
// Dismiss the broadcast result. This closes the like/tip modal.
|
||||
dismissResult () {
|
||||
this.showResultModal = false
|
||||
this.close()
|
||||
}
|
||||
|
||||
// Parse a non-empty tip string into an integer number of satoshis.
|
||||
_parseTip (input) {
|
||||
if (input === '' || input === null || input === undefined) return 0
|
||||
@@ -104,3 +145,11 @@ class LikeTipPage extends PageController {
|
||||
}
|
||||
|
||||
module.exports = LikeTipPage
|
||||
|
||||
LikeTipPage.EXPLORER_TX_BASE = BLOCK_EXPLORER_TX_BASE
|
||||
LikeTipPage.SUCCESS_MESSAGE = SUCCESS_MESSAGE
|
||||
LikeTipPage.explorerUrl = blockExplorerTxUrl
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-09-16T19:21:51.249Z","module_hash":"df3f5a3cd43fba8d3b7267561b21c84b68b964a958b6b77f0fa4b12f3afffc8d","functions":[{"id":"func/LikeTipPage.constructor","name":"LikeTipPage.constructor","line":22,"end_line":35,"hash":"020fe0af9dd6885fb83ad2edc0e94ef5d28661eec2a138fa5576743014341124"},{"id":"func/LikeTipPage.open","name":"LikeTipPage.open","line":39,"end_line":66,"hash":"0bfb69e7e8f6d9fc8ec2c923c20578215e8765088c514f8398461e64108aaaff"},{"id":"func/LikeTipPage.close","name":"LikeTipPage.close","line":69,"end_line":74,"hash":"673843dc179c1a325e224d6ad235f945d14b7efbceefb26f1b1f69dccfa4e535"},{"id":"func/LikeTipPage.setTip","name":"LikeTipPage.setTip","line":78,"end_line":80,"hash":"cfa38ba8b9f799839393de98e83d5512699327d99dbcf3de11d322be3a027323"},{"id":"func/LikeTipPage._setBusy","name":"LikeTipPage._setBusy","line":83,"end_line":85,"hash":"8fe4b97a14cdbd3849bd6bf493738a2caf50f36002de1c96603290b98c83e511"},{"id":"func/LikeTipPage.submit","name":"LikeTipPage.submit","line":90,"end_line":100,"hash":"d7a255241f316400a9c1f30b057ae7c63e27421d76432c8e02ecc88121319982"},{"id":"func/LikeTipPage.getBroadcastMessage","name":"LikeTipPage.getBroadcastMessage","line":103,"end_line":106,"hash":"800d20ba3a50dcffcee063bc5cd6422b548713520530b3d159cf1980310000b2"},{"id":"func/LikeTipPage.explorerUrl","name":"LikeTipPage.explorerUrl","line":109,"end_line":111,"hash":"7822bf8055c5fc45eaa46919b8665c8954def38f92bed94b091796a6a9b5381d"},{"id":"func/LikeTipPage.dismissResult","name":"LikeTipPage.dismissResult","line":114,"end_line":117,"hash":"96d1421749b8e3a3c295ad071d4bd921d17edd7c5e8824b1b3c3196b009cfcab"},{"id":"func/LikeTipPage._parseTip","name":"LikeTipPage._parseTip","line":120,"end_line":128,"hash":"7db17db65fe02da8db7d4658aa2fc38d52f61754c6e3a92cca5db6ebcf8eda5a"},{"id":"func/LikeTipPage._perform","name":"LikeTipPage._perform","line":131,"end_line":137,"hash":"f6af26bbf3965455c3436dd1da28bf22e27b65d28d4e02a9fe423a909efca030"},{"id":"func/LikeTipPage._handleSubmitFailure","name":"LikeTipPage._handleSubmitFailure","line":141,"end_line":144,"hash":"a66731eb7050a3c87ff7d228de3661facb9c971e7611d7953f86ba1b88bb1110"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
|
||||
const PageController = require('./page-controller')
|
||||
const MemoPost = require('./memo-post')
|
||||
const { BLOCK_EXPLORER_TX_BASE, blockExplorerTxUrl } = require('./block-explorer')
|
||||
|
||||
const NEW_POST_PATH = '/posts/new'
|
||||
const RECENT_FEED_PATH = '/posts/recent'
|
||||
const EXPLORER_TX_BASE = 'https://bch.loping.net/tx'
|
||||
|
||||
class NewPostPage extends PageController {
|
||||
constructor (deps = {}) {
|
||||
@@ -95,14 +95,11 @@ class NewPostPage extends PageController {
|
||||
|
||||
NewPostPage.NEW_POST_PATH = NEW_POST_PATH
|
||||
NewPostPage.RECENT_FEED_PATH = RECENT_FEED_PATH
|
||||
NewPostPage.EXPLORER_TX_BASE = EXPLORER_TX_BASE
|
||||
NewPostPage.explorerUrl = function (txid) {
|
||||
if (!txid) return ''
|
||||
return `${EXPLORER_TX_BASE}/${txid}`
|
||||
}
|
||||
NewPostPage.EXPLORER_TX_BASE = BLOCK_EXPLORER_TX_BASE
|
||||
NewPostPage.explorerUrl = blockExplorerTxUrl
|
||||
|
||||
module.exports = NewPostPage
|
||||
|
||||
// mutate4javascript-manifest-begin
|
||||
// {"version":1,"tested_at":"2026-09-13T13:36:17.184Z","module_hash":"65edafa07e390d316197347119a4d2a50c3fe73980c8e526f38fa0f66ee69689","functions":[{"id":"func/NewPostPage.constructor","name":"NewPostPage.constructor","line":24,"end_line":37,"hash":"1a42004b33ac28f396df02f70b2cec6a91f890d3a065aefe890c23cb1456ace3"},{"id":"func/NewPostPage.addMenuLink","name":"NewPostPage.addMenuLink","line":40,"end_line":43,"hash":"2bb99421f2cdbcf9a39f77244b250e4251b9bd3cc81a4f399589007f71486b79"},{"id":"func/NewPostPage.hasMenuLink","name":"NewPostPage.hasMenuLink","line":46,"end_line":48,"hash":"f2e167f20bd3040ebbcfaf3a3e95254333b9739e0fa93d7801ead542be2a15e3"},{"id":"func/NewPostPage.remainingCount","name":"NewPostPage.remainingCount","line":51,"end_line":53,"hash":"421d0af0e01278b479a5bb8d62d344498f875d188e8307eda420d637c23600d4"},{"id":"func/NewPostPage._setBusy","name":"NewPostPage._setBusy","line":56,"end_line":58,"hash":"1552af7690ee7a92b2a9ed84af1ba301c2468939b6994ca3942fe0b7004028fb"},{"id":"func/NewPostPage._perform","name":"NewPostPage._perform","line":61,"end_line":66,"hash":"c8b7394bcd5a2252278095e9af08cc5e76720b4fab08aa83218cb93fdb372407"},{"id":"func/NewPostPage.explorerUrl","name":"NewPostPage.explorerUrl","line":69,"end_line":71,"hash":"84a048fa78b38cef32573f1e31be11f870c765393213e59744d9799690da4d37"},{"id":"func/NewPostPage.submit","name":"NewPostPage.submit","line":75,"end_line":84,"hash":"1f15969b62987f29fbc310845716fe65c9ace8bec2f7bb2d8bee62e59b7cee95"},{"id":"func/NewPostPage.dismissResult","name":"NewPostPage.dismissResult","line":87,"end_line":93,"hash":"c155b35aedd68c7bbde87329ec086079621cf362ecf01472b65c7e442c9646b7"}]}
|
||||
// {"version":1,"tested_at":"2026-09-16T19:19:23.763Z","module_hash":"a285c6a2a27006b4e90960ebfe9968651e6cfd7a4f12e600fca76d521eda2751","functions":[{"id":"func/NewPostPage.constructor","name":"NewPostPage.constructor","line":24,"end_line":37,"hash":"ea4edf077e9c3631366acadc62f27b8cf4d5087cc6d2e317db9086b1d0d18347"},{"id":"func/NewPostPage.addMenuLink","name":"NewPostPage.addMenuLink","line":40,"end_line":43,"hash":"bac97164d2d70bfdb946c4d54e67983c43cad093bac558f1d169e1739ca97137"},{"id":"func/NewPostPage.hasMenuLink","name":"NewPostPage.hasMenuLink","line":46,"end_line":48,"hash":"7e1abf5d0833aaf3b3da3a024de9eb2b80da900b2930e832c7e92d77e0e50344"},{"id":"func/NewPostPage.remainingCount","name":"NewPostPage.remainingCount","line":51,"end_line":53,"hash":"521ce4ed841f62099529b327f2245a9e94c09af2f67ed5d91839e52607bcea37"},{"id":"func/NewPostPage._setBusy","name":"NewPostPage._setBusy","line":56,"end_line":58,"hash":"af4c51d75a9bbfc4d8c2566414ee950704d83831ad915ee04eea8bf2fab65a3e"},{"id":"func/NewPostPage._perform","name":"NewPostPage._perform","line":61,"end_line":66,"hash":"e66f893a4913b5d6f3cc4fcc1ad58e21557b4a316f0150050c961e3ece237795"},{"id":"func/NewPostPage.explorerUrl","name":"NewPostPage.explorerUrl","line":69,"end_line":71,"hash":"947125deea2b19ae2dff9d9bd9449a23f10b6bf6075a9c9be4bc588cd211ff56"},{"id":"func/NewPostPage.submit","name":"NewPostPage.submit","line":75,"end_line":84,"hash":"56282a4dea0927a6ab8bd3e6452f1860b978bcf4598d17cdcf86f6083b561b17"},{"id":"func/NewPostPage.dismissResult","name":"NewPostPage.dismissResult","line":87,"end_line":93,"hash":"35cae53c6e851cd96e5b81dfe5a2b954f458bdacd60d8228676779c025820c73"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -11,14 +11,9 @@
|
||||
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'
|
||||
const { BLOCK_EXPLORER_TX_BASE, blockExplorerTxUrl } = require('./block-explorer')
|
||||
|
||||
// Block explorer URL for a post transaction, or '' without a txid.
|
||||
function explorerTxUrl (txid) {
|
||||
if (!txid) return ''
|
||||
return `${BLOCK_EXPLORER_TX_BASE}/${txid}`
|
||||
}
|
||||
const BLOCK_EXPLORER_LABEL = 'See on block explorer'
|
||||
|
||||
// The ordered menu items for a post. The block explorer link is first.
|
||||
function postOptionsItems (txid) {
|
||||
@@ -26,7 +21,7 @@ function postOptionsItems (txid) {
|
||||
{
|
||||
id: 'block-explorer',
|
||||
label: BLOCK_EXPLORER_LABEL,
|
||||
href: explorerTxUrl(txid),
|
||||
href: blockExplorerTxUrl(txid),
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer'
|
||||
}
|
||||
@@ -99,7 +94,7 @@ function postOptionsKeyCommand (key, items = []) {
|
||||
module.exports = {
|
||||
BLOCK_EXPLORER_LABEL,
|
||||
BLOCK_EXPLORER_TX_BASE,
|
||||
explorerTxUrl,
|
||||
explorerTxUrl: blockExplorerTxUrl,
|
||||
postOptionsItems,
|
||||
initialPostOptionsState,
|
||||
openPostOptions,
|
||||
@@ -113,5 +108,5 @@ module.exports = {
|
||||
}
|
||||
|
||||
// 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"}]}
|
||||
// {"version":1,"tested_at":"2026-09-16T19:14:16.172Z","module_hash":"01043ede064dd77b6d74651fdbd61874be779a060c600d48c400d28194fc0348","functions":[{"id":"func/postOptionsItems","name":"postOptionsItems","line":19,"end_line":29,"hash":"a50c66c4fcc7255a2142df15b8714a7831c184b3fb58d09ce6c6c4795a031b5b"},{"id":"func/initialPostOptionsState","name":"initialPostOptionsState","line":32,"end_line":34,"hash":"1b6ab74976ac9fb54520f85e9fab7e61109572783da055866dcf64e36fc857f9"},{"id":"func/openPostOptions","name":"openPostOptions","line":37,"end_line":39,"hash":"4e91430330da69f5829a931fe6df67d2c6b7aa791f8092e1152e791c4935c384"},{"id":"func/closePostOptions","name":"closePostOptions","line":42,"end_line":44,"hash":"fe56a32123f3f5241891e22b551b0abba708bf182b2e04a9c1301d15e12fa466"},{"id":"func/togglePostOptions","name":"togglePostOptions","line":47,"end_line":49,"hash":"3bcb1a0d78907b5e83920c5f5e0d3d4fd55e0d62691d3c71bfc84bd1b9d8adac"},{"id":"func/focusFirstPostOption","name":"focusFirstPostOption","line":52,"end_line":55,"hash":"c3f174e7232a704c72bc79d84c4e5ef81ad1f32012a9c215d83c5a4eb7bd3291"},{"id":"func/handlePostOptionsEscape","name":"handlePostOptionsEscape","line":58,"end_line":60,"hash":"028896c476d907d1ca26361c5958c5996ac33a5455fa90ed8a7d48eaed9d2301"},{"id":"func/handlePostOptionsOutsideClick","name":"handlePostOptionsOutsideClick","line":63,"end_line":65,"hash":"66e71edaea9e5a93f14c2cb24026a338c02be5451f1ecdb00145705cc41e3ed0"},{"id":"func/isOutsidePostOptions","name":"isOutsidePostOptions","line":71,"end_line":73,"hash":"a35084f2e010d5e4726c25cac48b78c7e10fe331caf2ca3cabc663607cbf1962"},{"id":"func/postOptionsKeyCommand","name":"postOptionsKeyCommand","line":79,"end_line":92,"hash":"625fb7f69df3baf377fcd9a0b0d3d2bd129b31090e51591552f4eaa056fc120e"}]}
|
||||
// mutate4javascript-manifest-end
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
Property tests for the like broadcast result.
|
||||
|
||||
The unit tests probe the like result at a few fixed fixtures. These
|
||||
properties pin down the invariants over broad random inputs:
|
||||
|
||||
- explorerUrl composes the shared block explorer base with the txid and
|
||||
returns '' for every falsy input.
|
||||
- The LikeResult component always shows the message, shows the txid and an
|
||||
explorer link that opens in a new tab exactly when a txid is present, and
|
||||
is deterministic.
|
||||
- The result-modal state machine mirrors the last submit outcome: a
|
||||
successful like opens the result with the success message and keeps the
|
||||
modal open; a failed like shows no result. Dismissing always closes the
|
||||
result and the modal, and reopening always clears the previous result.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const React = require('react')
|
||||
const ReactDOMServer = require('react-dom/server')
|
||||
const { seededRandom, forAll, intGen } = require('./harness')
|
||||
const LikeTipPage = require('../../src/services/like-tip-page')
|
||||
const MemoLike = require('../../src/services/memo-like')
|
||||
const LikeResult = require('../../src/components/post-feed/like-result')
|
||||
|
||||
const rng = seededRandom(20260916)
|
||||
|
||||
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc'
|
||||
const LIKE_TXID = 'ab'.repeat(32)
|
||||
|
||||
const HEX = '0123456789abcdef'
|
||||
const SAFE_WORDS = ['like', 'broadcast', 'success', 'memo', 'network', 'tip', 'post']
|
||||
const FALSY = [undefined, null, '', 0, false]
|
||||
|
||||
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 randomMessage () {
|
||||
const n = intGen(rng, 1, 6)()
|
||||
let out = ''
|
||||
for (let i = 0; i < n; i++) {
|
||||
out += `${SAFE_WORDS[intGen(rng, 0, SAFE_WORDS.length - 1)()]} `
|
||||
}
|
||||
return out.trim()
|
||||
}
|
||||
|
||||
function makeWallet (failWith) {
|
||||
return {
|
||||
walletInfo: { cashAddress: MY_ADDRESS },
|
||||
utxos: [{ txid: 'utxo', value: 100000 }],
|
||||
broadcasts: [],
|
||||
async getUtxos () {
|
||||
return this.utxos
|
||||
},
|
||||
async sendOpReturn (msg, prefix, bchOutput = []) {
|
||||
this.broadcasts.push({ msg, prefix, bchOutput })
|
||||
if (failWith) throw new Error(failWith)
|
||||
return LIKE_TXID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeSubmittedPage (fail) {
|
||||
const memoLike = new MemoLike({ wallet: makeWallet(fail ? 'Insufficient balance' : null) })
|
||||
const page = new LikeTipPage({ memoLike })
|
||||
page.open(randomTxid(), AUTHOR_ADDRESS)
|
||||
page.setTip('')
|
||||
return page
|
||||
}
|
||||
|
||||
function render (props) {
|
||||
return ReactDOMServer.renderToStaticMarkup(
|
||||
React.createElement(LikeResult, props)
|
||||
)
|
||||
}
|
||||
|
||||
test('explorerUrl composes the shared base and the txid', async () => {
|
||||
await forAll(
|
||||
() => randomTxid(),
|
||||
async (txid) =>
|
||||
LikeTipPage.explorerUrl(txid) === `${LikeTipPage.EXPLORER_TX_BASE}/${txid}`,
|
||||
{ label: 'like explorer url composition', samples: 2000 }
|
||||
)
|
||||
})
|
||||
|
||||
test('explorerUrl returns an empty string for every falsy input', async () => {
|
||||
await forAll(
|
||||
() => FALSY[intGen(rng, 0, FALSY.length - 1)()],
|
||||
async (value) => LikeTipPage.explorerUrl(value) === '',
|
||||
{ label: 'like explorer url falsy inputs', samples: 500 }
|
||||
)
|
||||
})
|
||||
|
||||
test('LikeResult always shows the message and links only with a txid', async () => {
|
||||
await forAll(
|
||||
() => ({ txid: rng() < 0.2 ? '' : randomTxid(), message: randomMessage() }),
|
||||
async ({ txid, message }) => {
|
||||
const url = LikeTipPage.explorerUrl(txid)
|
||||
const html = render({ txid, message, explorerUrl: url })
|
||||
if (!html.includes(message)) return false
|
||||
if (txid) {
|
||||
if (!html.includes(txid)) return false
|
||||
if (!html.includes(`href="${url}"`)) return false
|
||||
if (!html.includes('target="_blank"')) return false
|
||||
} else if (html.includes('<a ')) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
{ label: 'like result markup', samples: 1500 }
|
||||
)
|
||||
})
|
||||
|
||||
test('rendering the same LikeResult props twice yields the same markup', async () => {
|
||||
await forAll(
|
||||
() => ({ txid: randomTxid(), message: randomMessage() }),
|
||||
async (props) => {
|
||||
const first = render(props)
|
||||
const second = render(props)
|
||||
return first === second
|
||||
},
|
||||
{ label: 'like result render determinism', samples: 500 }
|
||||
)
|
||||
})
|
||||
|
||||
test('the result modal mirrors the last submit outcome', async () => {
|
||||
await forAll(
|
||||
() => rng() < 0.4,
|
||||
async (fail) => {
|
||||
const page = makeSubmittedPage(fail)
|
||||
const result = await page.submit()
|
||||
if (result.ok) {
|
||||
if (page.showResultModal !== true || page.modalOpen !== true) return false
|
||||
if (page.getBroadcastMessage() !== LikeTipPage.SUCCESS_MESSAGE) return false
|
||||
} else {
|
||||
if (page.showResultModal !== false) return false
|
||||
if (page.getBroadcastMessage() !== '') return false
|
||||
}
|
||||
return true
|
||||
},
|
||||
{ label: 'like result modal outcome', samples: 400 }
|
||||
)
|
||||
})
|
||||
|
||||
test('dismissing the result always closes the result and the modal', async () => {
|
||||
await forAll(
|
||||
() => rng() < 0.5,
|
||||
async (fail) => {
|
||||
const page = makeSubmittedPage(fail)
|
||||
await page.submit()
|
||||
page.dismissResult()
|
||||
return page.showResultModal === false && page.modalOpen === false
|
||||
},
|
||||
{ label: 'like result dismiss', samples: 400 }
|
||||
)
|
||||
})
|
||||
|
||||
test('reopening the like/tip modal always clears the previous result', async () => {
|
||||
await forAll(
|
||||
() => rng() < 0.5,
|
||||
async (fail) => {
|
||||
const page = makeSubmittedPage(fail)
|
||||
await page.submit()
|
||||
page.open(randomTxid(), AUTHOR_ADDRESS)
|
||||
return page.showResultModal === false && page.lastResult === null
|
||||
},
|
||||
{ label: 'like result reopen clears', samples: 400 }
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
Unit tests for the shared block explorer link.
|
||||
|
||||
The New Post result modal, the post options menu, and the like/tip broadcast
|
||||
result all use this module so the explorer base URL and link shape stay in
|
||||
one place.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const {
|
||||
BLOCK_EXPLORER_TX_BASE,
|
||||
blockExplorerTxUrl
|
||||
} = require('../../src/services/block-explorer')
|
||||
|
||||
const SAMPLE_TXID = '1111111111111111111111111111111111111111111111111111111111111111'
|
||||
|
||||
test('the block explorer base points at bch.loping.net', () => {
|
||||
assert.equal(BLOCK_EXPLORER_TX_BASE, 'https://bch.loping.net/tx')
|
||||
})
|
||||
|
||||
test('blockExplorerTxUrl builds a transaction link', () => {
|
||||
assert.equal(
|
||||
blockExplorerTxUrl(SAMPLE_TXID),
|
||||
`${BLOCK_EXPLORER_TX_BASE}/${SAMPLE_TXID}`
|
||||
)
|
||||
})
|
||||
|
||||
test('blockExplorerTxUrl returns an empty string without a txid', () => {
|
||||
assert.equal(blockExplorerTxUrl(''), '')
|
||||
assert.equal(blockExplorerTxUrl(null), '')
|
||||
assert.equal(blockExplorerTxUrl(undefined), '')
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
Unit tests for the like broadcast result component.
|
||||
|
||||
The component shows the broadcast success message, the like transaction id,
|
||||
and a link to that transaction on the block explorer that opens in a new tab.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const React = require('react')
|
||||
const ReactDOMServer = require('react-dom/server')
|
||||
const LikeResult = require('../../src/components/post-feed/like-result')
|
||||
|
||||
const SAMPLE_TXID = '1111111111111111111111111111111111111111111111111111111111111111'
|
||||
const MESSAGE = 'Your like was broadcast to the Bitcoin Cash network.'
|
||||
const EXPLORER_URL = `https://bch.loping.net/tx/${SAMPLE_TXID}`
|
||||
|
||||
function renderResult (props = {}) {
|
||||
return ReactDOMServer.renderToStaticMarkup(
|
||||
React.createElement(LikeResult, {
|
||||
txid: SAMPLE_TXID,
|
||||
message: MESSAGE,
|
||||
explorerUrl: EXPLORER_URL,
|
||||
...props
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
test('renders the broadcast success message', () => {
|
||||
const html = renderResult()
|
||||
|
||||
assert.ok(html.includes(MESSAGE))
|
||||
})
|
||||
|
||||
test('renders the like transaction id', () => {
|
||||
const html = renderResult()
|
||||
|
||||
assert.ok(html.includes(SAMPLE_TXID))
|
||||
})
|
||||
|
||||
test('renders a link to the block explorer', () => {
|
||||
const html = renderResult()
|
||||
|
||||
assert.match(html, new RegExp(`href="${EXPLORER_URL}"`))
|
||||
})
|
||||
|
||||
test('the explorer link opens in a new tab', () => {
|
||||
const html = renderResult()
|
||||
|
||||
assert.match(html, /target="_blank"/)
|
||||
})
|
||||
|
||||
test('renders no link without a transaction id', () => {
|
||||
const html = renderResult({ txid: '' })
|
||||
|
||||
assert.ok(!html.includes('<a '))
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
Unit tests for the like/tip page controller's broadcast result.
|
||||
|
||||
After a like is broadcast, the like/tip modal no longer closes
|
||||
automatically. The controller records the successful result and keeps the
|
||||
modal open so the modal can show a broadcast result (message, txid, and
|
||||
explorer link). Dismissing the result closes the modal.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const LikeTipPage = require('../../src/services/like-tip-page')
|
||||
const MemoLike = require('../../src/services/memo-like')
|
||||
|
||||
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc'
|
||||
const SAMPLE_TXID = '1111111111111111111111111111111111111111111111111111111111111111'
|
||||
const LIKE_TXID = 'ab'.repeat(32)
|
||||
|
||||
function makeWallet () {
|
||||
return {
|
||||
walletInfo: { cashAddress: MY_ADDRESS },
|
||||
utxos: [{ txid: 'utxo', value: 100000 }],
|
||||
broadcasts: [],
|
||||
async getUtxos () {
|
||||
return this.utxos
|
||||
},
|
||||
async sendOpReturn (msg, prefix, bchOutput = []) {
|
||||
this.broadcasts.push({ msg, prefix, bchOutput })
|
||||
if (this.failWith) throw new Error(this.failWith)
|
||||
return LIKE_TXID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makePage (opts = {}) {
|
||||
const wallet = makeWallet()
|
||||
if (opts.balance !== undefined) {
|
||||
wallet.utxos = [{ txid: 'utxo', value: opts.balance }]
|
||||
}
|
||||
if (opts.failWith) wallet.failWith = opts.failWith
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
return { wallet, page: new LikeTipPage({ memoLike }) }
|
||||
}
|
||||
|
||||
// Open the modal, submit a like with no tip, and return the page and result.
|
||||
async function submitLike (opts = {}) {
|
||||
const { wallet, page } = makePage(opts)
|
||||
page.open(SAMPLE_TXID, AUTHOR_ADDRESS)
|
||||
page.setTip('')
|
||||
const result = await page.submit()
|
||||
return { wallet, page, result }
|
||||
}
|
||||
|
||||
test('the result modal starts hidden', () => {
|
||||
const { page } = makePage()
|
||||
|
||||
assert.equal(page.showResultModal, false)
|
||||
assert.equal(page.lastResult, null)
|
||||
assert.equal(page.modalOpen, false)
|
||||
assert.equal(page.tipping, false)
|
||||
})
|
||||
|
||||
test('the controller seeds the post txid and author from deps', () => {
|
||||
const page = new LikeTipPage({
|
||||
postTxid: SAMPLE_TXID,
|
||||
authorAddress: AUTHOR_ADDRESS
|
||||
})
|
||||
|
||||
assert.equal(page.postTxid, SAMPLE_TXID)
|
||||
assert.equal(page.authorAddress, AUTHOR_ADDRESS)
|
||||
})
|
||||
|
||||
test('explorerUrl builds a bch.loping.net transaction link', () => {
|
||||
assert.equal(
|
||||
LikeTipPage.explorerUrl(SAMPLE_TXID),
|
||||
`https://bch.loping.net/tx/${SAMPLE_TXID}`
|
||||
)
|
||||
assert.equal(LikeTipPage.explorerUrl(''), '')
|
||||
|
||||
const { page } = makePage()
|
||||
assert.equal(page.explorerUrl(SAMPLE_TXID), LikeTipPage.explorerUrl(SAMPLE_TXID))
|
||||
})
|
||||
|
||||
test('SUCCESS_MESSAGE announces the broadcast', () => {
|
||||
assert.equal(typeof LikeTipPage.SUCCESS_MESSAGE, 'string')
|
||||
assert.match(LikeTipPage.SUCCESS_MESSAGE, /broadcast/i)
|
||||
})
|
||||
|
||||
test('a successful like keeps the modal open and shows the result', async () => {
|
||||
const { page, result } = await submitLike()
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(result.txid, LIKE_TXID)
|
||||
assert.equal(page.modalOpen, true)
|
||||
assert.equal(page.showResultModal, true)
|
||||
assert.equal(page.lastResult.txid, LIKE_TXID)
|
||||
assert.equal(page.getBroadcastMessage(), LikeTipPage.SUCCESS_MESSAGE)
|
||||
})
|
||||
|
||||
test('dismissing the result closes the like/tip modal', async () => {
|
||||
const { page } = await submitLike()
|
||||
|
||||
page.dismissResult()
|
||||
|
||||
assert.equal(page.showResultModal, false)
|
||||
assert.equal(page.modalOpen, false)
|
||||
})
|
||||
|
||||
test('opening the modal clears any previous result', async () => {
|
||||
const { page } = await submitLike()
|
||||
|
||||
page.open(SAMPLE_TXID, AUTHOR_ADDRESS)
|
||||
|
||||
assert.equal(page.showResultModal, false)
|
||||
assert.equal(page.lastResult, null)
|
||||
})
|
||||
|
||||
test('a validation failure stays on the form and opens no result', async () => {
|
||||
const { page } = makePage()
|
||||
page.open(SAMPLE_TXID, AUTHOR_ADDRESS)
|
||||
page.setTip('abc')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error, 'like_validation')
|
||||
assert.equal(page.broadcastError, 'Tip must be a valid number of satoshis.')
|
||||
assert.equal(page.modalOpen, true)
|
||||
assert.equal(page.showResultModal, false)
|
||||
assert.equal(page.lastResult.ok, false)
|
||||
})
|
||||
|
||||
test('a broadcast failure stays on the form and opens no result', async () => {
|
||||
const { page } = makePage({ failWith: 'Insufficient balance' })
|
||||
page.open(SAMPLE_TXID, AUTHOR_ADDRESS)
|
||||
page.setTip('')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error, 'broadcast')
|
||||
assert.equal(page.broadcastError, 'Insufficient balance')
|
||||
assert.equal(page.modalOpen, true)
|
||||
assert.equal(page.showResultModal, false)
|
||||
assert.equal(page.getBroadcastMessage(), '')
|
||||
})
|
||||
|
||||
test('open reports a validation error without a memo like handler', () => {
|
||||
const page = new LikeTipPage({})
|
||||
|
||||
const result = page.open(SAMPLE_TXID, AUTHOR_ADDRESS)
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error, 'like_validation')
|
||||
assert.match(result.message, /memo like handler/)
|
||||
assert.equal(page.modalOpen, true)
|
||||
assert.equal(page.showResultModal, false)
|
||||
})
|
||||
|
||||
test('open reports an empty-balance error below the dust limit', () => {
|
||||
const { page } = makePage({ balance: 100 })
|
||||
|
||||
const result = page.open(SAMPLE_TXID, AUTHOR_ADDRESS)
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error, 'like_empty_balance')
|
||||
assert.match(page.broadcastError, /add BCH/)
|
||||
assert.equal(page.showResultModal, false)
|
||||
})
|
||||
|
||||
test('open accepts a balance exactly at the dust limit', () => {
|
||||
const { page } = makePage({ balance: 3000 })
|
||||
|
||||
const result = page.open(SAMPLE_TXID, AUTHOR_ADDRESS)
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(page.submitError, null)
|
||||
assert.equal(page.broadcastError, null)
|
||||
})
|
||||
|
||||
test('submit fails without a memo like handler', async () => {
|
||||
const page = new LikeTipPage({})
|
||||
page.setTip('')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error, 'broadcast')
|
||||
assert.match(page.broadcastError, /memo like handler/)
|
||||
assert.equal(page.showResultModal, false)
|
||||
})
|
||||
|
||||
test('a like with a positive tip sends the tip to the author', async () => {
|
||||
const { wallet, page } = makePage()
|
||||
page.open(SAMPLE_TXID, AUTHOR_ADDRESS)
|
||||
page.setTip('600')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
assert.deepEqual(wallet.broadcasts[0].bchOutput, [
|
||||
{ address: AUTHOR_ADDRESS, amountSat: 600 }
|
||||
])
|
||||
})
|
||||
|
||||
test('an empty, null, or undefined tip parses as zero', () => {
|
||||
const { page } = makePage()
|
||||
|
||||
assert.equal(page._parseTip(''), 0)
|
||||
assert.equal(page._parseTip(null), 0)
|
||||
assert.equal(page._parseTip(undefined), 0)
|
||||
})
|
||||
@@ -38,10 +38,12 @@ function makeMemoPost (opts = {}) {
|
||||
return { wallet, feed, memoPost: new MemoPost({ wallet, feed }) }
|
||||
}
|
||||
|
||||
test('the in-flight flag starts false', () => {
|
||||
test('the in-flight and result-modal flags start false', () => {
|
||||
const page = new NewPostPage({ navigate: () => {} })
|
||||
|
||||
assert.equal(page.posting, false)
|
||||
assert.equal(page.showResultModal, false)
|
||||
assert.equal(page.lastResult, null)
|
||||
})
|
||||
|
||||
test('NEW_POST_PATH and RECENT_FEED_PATH constants', () => {
|
||||
|
||||
Reference in New Issue
Block a user