diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index c128a24..3e49045 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -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,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', pattern: /^the psf-memo-db API serves a post with txid (.+) authored by the address (.+) with a like count of (.+)$/, diff --git a/psf-memo-client/acceptance/lib/render-like-result.js b/psf-memo-client/acceptance/lib/render-like-result.js new file mode 100644 index 0000000..7b0ef4b --- /dev/null +++ b/psf-memo-client/acceptance/lib/render-like-result.js @@ -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 } diff --git a/psf-memo-client/src/components/post-feed/like-result.js b/psf-memo-client/src/components/post-feed/like-result.js new file mode 100644 index 0000000..9622762 --- /dev/null +++ b/psf-memo-client/src/components/post-feed/like-result.js @@ -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 diff --git a/psf-memo-client/src/components/post-feed/like-tip-modal.js b/psf-memo-client/src/components/post-feed/like-tip-modal.js index 073463d..44bd3b7 100644 --- a/psf-memo-client/src/components/post-feed/like-tip-modal.js +++ b/psf-memo-client/src/components/post-feed/like-tip-modal.js @@ -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 ( @@ -108,47 +123,69 @@ function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess }) - {post && ( + {post && !showResult && (

Like the post by {displayName}

)} -
{ e.preventDefault(); handleSubmit() }}> - - Tip (satoshis, optional) - { - setTip(e.target.value) - // Clear a previous validation error so the user can retry. - setError('') - }} - disabled={submitting} + {showResult + ? ( + - -
+ ) + : ( + <> +
{ e.preventDefault(); handleSubmit() }}> + + Tip (satoshis, optional) + { + setTip(e.target.value) + // Clear a previous validation error so the user can retry. + setError('') + }} + disabled={submitting} + /> + +
- {error && ( -

{error}

- )} + {error && ( +

{error}

+ )} + + )}
- - + {showResult + ? ( + + ) + : ( + <> + + + + )}
) 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 82a0f0b..76e1559 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 @@ -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 = () => { 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 e7d142d..6a04b3b 100644 --- a/psf-memo-client/src/components/post-feed/post-feed.css +++ b/psf-memo-client/src/components/post-feed/post-feed.css @@ -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%; diff --git a/psf-memo-client/src/services/like-tip-page.js b/psf-memo-client/src/services/like-tip-page.js index 7a0c48c..1095360 100644 --- a/psf-memo-client/src/services/like-tip-page.js +++ b/psf-memo-client/src/services/like-tip-page.js @@ -15,6 +15,9 @@ 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 { constructor (deps = {}) { super(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,10 @@ class LikeTipPage extends PageController { } 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}` +} diff --git a/psf-memo-client/test/unit/like-result.test.js b/psf-memo-client/test/unit/like-result.test.js new file mode 100644 index 0000000..ff964ea --- /dev/null +++ b/psf-memo-client/test/unit/like-result.test.js @@ -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(' { + 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(), '') +})