Implement like broadcast result modal

Keep the like/tip modal open after a successful like and show a broadcast
result instead of closing: the success message, the like transaction id, and
a link to that transaction on bch.loping.net that opens in a new tab. The
like count and filled heart update immediately, and dismissing the result
closes the modal. The result state lives on the testable LikeTipPage
controller; a server-renderable LikeResult component backs the modal and the
acceptance adapter, with acceptance step handlers for the new wording.

By coder.
This commit is contained in:
Chris Troutner
2026-09-16 11:41:49 -07:00
parent 3af0d42c67
commit a0040f98c1
9 changed files with 471 additions and 33 deletions
@@ -55,6 +55,7 @@ const PollVotePage = require('../../src/services/poll-vote-page')
const { renderPostText } = require('./render-post') const { renderPostText } = require('./render-post')
const { renderAccountAvatar } = require('./render-account-avatar') const { renderAccountAvatar } = require('./render-account-avatar')
const { renderPostOptions } = require('./render-post-options') const { renderPostOptions } = require('./render-post-options')
const { renderLikeResult } = require('./render-like-result')
const PostOptions = require('../../src/services/post-options') const PostOptions = require('../../src/services/post-options')
const { YOUTUBE_EMBED_BASE_URL } = require('../../src/services/youtube-embed') const { YOUTUBE_EMBED_BASE_URL } = require('../../src/services/youtube-embed')
@@ -1493,6 +1494,92 @@ 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 page = world.likeTipPage
if (!page.showResultModal) {
throw new Error('Expected the like broadcast result to be shown.')
}
const message = page.getBroadcastMessage()
if (!message) {
throw new Error('Expected a like broadcast success message.')
}
const html = renderLikeResult({
txid: page.lastResult.txid,
message,
explorerUrl: page.explorerUrl(page.lastResult.txid)
})
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 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 html = renderLikeResult({
txid,
message: page.getBroadcastMessage(),
explorerUrl: page.explorerUrl(txid)
})
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 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
const url = page.explorerUrl(txid)
if (!url.startsWith('https://bch.loping.net/tx/')) {
throw new Error(`Expected a bch.loping.net explorer link, got "${url}".`)
}
const html = renderLikeResult({
txid,
message: page.getBroadcastMessage(),
explorerUrl: 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', 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 (.+)$/, pattern: /^the psf-memo-db API serves a post with txid (.+) authored by the address (.+) with a like count of (.+)$/,
@@ -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 }
@@ -0,0 +1,40 @@
/*
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
@@ -12,6 +12,7 @@ import { Modal, Form, Button } from 'react-bootstrap'
import MemoLike from '../../services/memo-like' import MemoLike from '../../services/memo-like'
import LikeTipPage from '../../services/like-tip-page' import LikeTipPage from '../../services/like-tip-page'
import LikeResult from './like-result'
import { getDisplayName } from './post-display' import { getDisplayName } from './post-display'
import './post-feed.css' import './post-feed.css'
@@ -24,6 +25,8 @@ function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess })
const [tip, setTip] = useState('') const [tip, setTip] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [resultTxid, setResultTxid] = useState('')
const [showResult, setShowResult] = useState(false)
const displayName = post ? getDisplayName(post.addr, profiles) : '' const displayName = post ? getDisplayName(post.addr, profiles) : ''
@@ -33,12 +36,16 @@ function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess })
setTip('') setTip('')
setError('') setError('')
setSubmitting(false) setSubmitting(false)
setResultTxid('')
setShowResult(false)
return return
} }
setTip('') setTip('')
setError('') setError('')
setSubmitting(false) setSubmitting(false)
setResultTxid('')
setShowResult(false)
let cancelled = false let cancelled = false
@@ -82,6 +89,8 @@ function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess })
const result = await page.submit() const result = await page.submit()
if (result.ok) { if (result.ok) {
setTip('') setTip('')
setResultTxid(result.txid)
setShowResult(true)
if (typeof onSuccess === 'function') { if (typeof onSuccess === 'function') {
onSuccess() onSuccess()
} }
@@ -95,12 +104,18 @@ function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess })
} }
} }
function handleCancel () { function handleDismiss () {
setTip('') setTip('')
setError('') setError('')
setResultTxid('')
setShowResult(false)
onHide() onHide()
} }
function handleCancel () {
handleDismiss()
}
return ( return (
<Modal show={show} onHide={handleCancel} centered> <Modal show={show} onHide={handleCancel} centered>
<Modal.Header closeButton> <Modal.Header closeButton>
@@ -108,47 +123,69 @@ function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess })
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
{post && ( {post && !showResult && (
<p className='like-tip-modal-target'> <p className='like-tip-modal-target'>
Like the post by <strong>{displayName}</strong> Like the post by <strong>{displayName}</strong>
</p> </p>
)} )}
<Form onSubmit={(e) => { e.preventDefault(); handleSubmit() }}> {showResult
<Form.Group controlId='like-tip-amount' className='mb-3'> ? (
<Form.Label>Tip (satoshis, optional)</Form.Label> <LikeResult
<Form.Control txid={resultTxid}
type='number' message={LikeTipPage.SUCCESS_MESSAGE}
min='0' explorerUrl={LikeTipPage.explorerUrl(resultTxid)}
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> : (
<>
<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 && ( {error && (
<p className='like-tip-modal-error text-danger'>{error}</p> <p className='like-tip-modal-error text-danger'>{error}</p>
)} )}
</>
)}
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
<Button variant='secondary' onClick={handleCancel}> {showResult
Cancel ? (
</Button> <Button variant='primary' onClick={handleDismiss}>
<Button Close
variant='primary' </Button>
onClick={handleSubmit} )
disabled={submitting || !post || !wallet} : (
> <>
{submitting ? 'Liking...' : 'Like'} <Button variant='secondary' onClick={handleCancel}>
</Button> Cancel
</Button>
<Button
variant='primary'
onClick={handleSubmit}
disabled={submitting || !post || !wallet}
>
{submitting ? 'Liking...' : 'Like'}
</Button>
</>
)}
</Modal.Footer> </Modal.Footer>
</Modal> </Modal>
) )
@@ -77,9 +77,10 @@ function PostFeedItem ({
} }
const handleLikeSuccess = () => { const handleLikeSuccess = () => {
// Reflect the like immediately. The modal stays open to show the
// broadcast result until the user dismisses it.
setLiked(true) setLiked(true)
setLikeCount((count) => count + 1) setLikeCount((count) => count + 1)
setShowLikeModal(false)
} }
const handleLikeModalHide = () => { const handleLikeModalHide = () => {
@@ -681,6 +681,18 @@
font-size: 0.95rem; 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 { .posts-feed-item-youtube {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -15,6 +15,9 @@
const PageController = require('./page-controller') const PageController = require('./page-controller')
const EXPLORER_TX_BASE = 'https://bch.loping.net/tx'
const SUCCESS_MESSAGE = 'Your like was broadcast to the Bitcoin Cash network.'
class LikeTipPage extends PageController { class LikeTipPage extends PageController {
constructor (deps = {}) { constructor (deps = {}) {
super(deps) super(deps)
@@ -25,6 +28,10 @@ class LikeTipPage extends PageController {
this.authorAddress = deps.authorAddress || '' this.authorAddress = deps.authorAddress || ''
this.successPath = null this.successPath = null
this.validationCodes = ['like_validation', 'like_dust', 'like_maximum', 'like_balance', 'like_empty_balance'] 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 // 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.postTxid = postTxid
this.authorAddress = authorAddress this.authorAddress = authorAddress
this.modalOpen = true this.modalOpen = true
this.showResultModal = false
this.lastResult = null
this.submitError = null this.submitError = null
this.broadcastError = null this.broadcastError = null
@@ -75,6 +84,38 @@ class LikeTipPage extends PageController {
this.tipping = value 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. // Parse a non-empty tip string into an integer number of satoshis.
_parseTip (input) { _parseTip (input) {
if (input === '' || input === null || input === undefined) return 0 if (input === '' || input === null || input === undefined) return 0
@@ -104,3 +145,10 @@ class LikeTipPage extends PageController {
} }
module.exports = LikeTipPage module.exports = LikeTipPage
LikeTipPage.EXPLORER_TX_BASE = EXPLORER_TX_BASE
LikeTipPage.SUCCESS_MESSAGE = SUCCESS_MESSAGE
LikeTipPage.explorerUrl = function (txid) {
if (!txid) return ''
return `${EXPLORER_TX_BASE}/${txid}`
}
@@ -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,134 @@
/*
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.failWith) wallet.failWith = opts.failWith
const memoLike = new MemoLike({ wallet })
return { wallet, page: new LikeTipPage({ memoLike }) }
}
test('the result modal starts hidden', () => {
const { page } = makePage()
assert.equal(page.showResultModal, false)
assert.equal(page.lastResult, null)
})
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 } = makePage()
page.open(SAMPLE_TXID, AUTHOR_ADDRESS)
page.setTip('')
const result = await page.submit()
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 } = makePage()
page.open(SAMPLE_TXID, AUTHOR_ADDRESS)
page.setTip('')
await page.submit()
page.dismissResult()
assert.equal(page.showResultModal, false)
assert.equal(page.modalOpen, false)
})
test('opening the modal clears any previous result', async () => {
const { page } = makePage()
page.open(SAMPLE_TXID, AUTHOR_ADDRESS)
page.setTip('')
await page.submit()
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.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.match(page.broadcastError, /Insufficient balance/)
assert.equal(page.modalOpen, true)
assert.equal(page.showResultModal, false)
assert.equal(page.getBroadcastMessage(), '')
})