From 8244e11ec4544d09f54d742e7031900b245c3e7d Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sun, 13 Sep 2026 06:42:14 -0700 Subject: [PATCH] New posts display TXID after broadcast --- psf-memo-client/acceptance/lib/handlers.js | 50 ++++++ psf-memo-client/specs/memo-new.feature | 7 +- .../src/components/app-body/new-post/index.js | 75 ++++++-- psf-memo-client/src/services/new-post.js | 44 ++++- .../test/unit/new-post-page.test.js | 168 ++++++++++++++++++ test/unit/new-post.test.js | 33 +++- 6 files changed, 360 insertions(+), 17 deletions(-) create mode 100644 psf-memo-client/test/unit/new-post-page.test.js diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 1d5953e..3598d83 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -1109,6 +1109,56 @@ const handlers = [ } } }, + { + name: 'page shows success modal with txid', + pattern: /^the new post page shows a success modal with the post txid$/, + run (m, example, world) { + const page = world.newPage + const txid = page.lastResult && page.lastResult.txid + if (!page.showResultModal) { + throw new Error('Expected the new post success modal to be visible.') + } + if (!page.lastResult || !page.lastResult.ok) { + throw new Error('Expected a successful post result in the modal.') + } + if (!txid) { + throw new Error('Expected the success modal to include a post txid.') + } + const expectedUrl = NewPostPage.explorerUrl(txid) + if (page.explorerUrl(txid) !== expectedUrl) { + throw new Error(`Expected explorer URL ${expectedUrl}, got ${page.explorerUrl(txid)}.`) + } + if (!expectedUrl.startsWith(NewPostPage.EXPLORER_TX_BASE + '/')) { + throw new Error(`Explorer URL ${expectedUrl} is not on bch.loping.net.`) + } + } + }, + { + name: 'page shows failure modal containing text', + pattern: /^the new post page shows a failure modal containing "<([A-Za-z0-9_]+)>"$/, + run (m, example, world) { + const param = m[1] + const expected = example[param] + const page = world.newPage + const actual = (page.lastResult && page.lastResult.message) || page.broadcastError || '' + if (!page.showResultModal) { + throw new Error('Expected the new post failure modal to be visible.') + } + if (page.lastResult && page.lastResult.ok) { + throw new Error('Expected a failed post result in the modal.') + } + if (!actual.includes(expected)) { + throw new Error(`Expected a failure modal containing "${expected}", got "${actual}".`) + } + } + }, + { + name: 'dismiss result modal', + pattern: /^I dismiss the result modal$/, + run (m, example, world) { + world.newPage.dismissResult() + } + }, { name: 'page shows validation/length error', pattern: /^the (?:app|new post page) shows a (validation|length) error$/, diff --git a/psf-memo-client/specs/memo-new.feature b/psf-memo-client/specs/memo-new.feature index 4f34827..a335644 100644 --- a/psf-memo-client/specs/memo-new.feature +++ b/psf-memo-client/specs/memo-new.feature @@ -14,6 +14,9 @@ Feature: New Post Page When I type a memo with the text "" When I click the post button Then the app broadcasts an OP_RETURN transaction with the Memo post prefix + Then the new post page shows a success modal with the post txid + Then I remain on the path /posts/new + When I dismiss the result modal Then I navigate to the path /posts/recent Then the feed shows a new post from my address with the text "" @@ -66,7 +69,9 @@ Feature: New Post Page When I type a memo with the text "" When I click the post button Then the app attempts to broadcast an OP_RETURN transaction with the Memo post prefix - Then the new post page shows an error containing "" + Then the new post page shows a failure modal containing "" + Then I remain on the path /posts/new + When I dismiss the result modal Then I remain on the path /posts/new Examples: diff --git a/psf-memo-client/src/components/app-body/new-post/index.js b/psf-memo-client/src/components/app-body/new-post/index.js index 23b344e..4d0e491 100644 --- a/psf-memo-client/src/components/app-body/new-post/index.js +++ b/psf-memo-client/src/components/app-body/new-post/index.js @@ -1,12 +1,13 @@ /* New Post view: compose and broadcast a Memo post, with a character counter - that counts down from the memo limit. On success the user is navigated to the - recent feed. + that counts down from the memo limit. After broadcast, a modal reports + success (txid + explorer link) or failure. Dismissing a successful result + navigates to the recent feed. */ // Global npm libraries -import React, { useState } from 'react' -import { Container, Row, Col, Form, Button } from 'react-bootstrap' +import React, { useRef, useState } from 'react' +import { Container, Row, Col, Form, Button, Modal } from 'react-bootstrap' import { useNavigate } from 'react-router-dom' // Local libraries @@ -21,8 +22,13 @@ function NewPost (props) { const [input, setInput] = useState('') const [err, setErr] = useState('') const [posting, setPosting] = useState(false) + const [showResultModal, setShowResultModal] = useState(false) + const [lastResult, setLastResult] = useState(null) + const pageRef = useRef(null) const remaining = maxChars - input.length + const resultTxid = lastResult && lastResult.ok ? lastResult.txid : '' + const explorerUrl = NewPostPage.explorerUrl(resultTxid) async function handleSubmit (event) { event.preventDefault() @@ -32,28 +38,39 @@ function NewPost (props) { try { const memoPost = new MemoPost({ wallet: appData?.wallet }) const page = new NewPostPage({ memoPost, navigate }) + pageRef.current = page page.setInput(input) const result = await page.submit() + setLastResult(result) if (!result.ok) { if (result.error === 'memo_length') { setErr(`Memo is too long. Maximum is ${maxChars} characters.`) } else if (result.error === 'memo_validation') { setErr('Memo must not be empty.') - } else if (result.message) { - setErr(`Failed to broadcast: ${result.message}`) - } else { - setErr('Failed to post memo.') } } - // On success page.submit() navigated to the recent feed. + if (page.showResultModal) { + setShowResultModal(true) + } } catch (submitErr) { - setErr(submitErr.message) + const result = { ok: false, error: 'broadcast', message: submitErr.message } + setLastResult(result) + setShowResultModal(true) } finally { setPosting(false) } } + function handleDismissResult () { + if (pageRef.current) { + pageRef.current.dismissResult() + } else if (lastResult && lastResult.ok) { + navigate(NewPostPage.RECENT_FEED_PATH) + } + setShowResultModal(false) + } + return ( @@ -72,6 +89,7 @@ function NewPost (props) { value={input} onChange={(e) => setInput(e.target.value)} placeholder='Write your Memo here...' + disabled={posting} /> @@ -87,6 +105,43 @@ function NewPost (props) { + + + + + {lastResult && lastResult.ok ? 'Post published' : 'Post failed'} + + + + {lastResult && lastResult.ok + ? ( + <> +

Your post was broadcast to the Bitcoin Cash network.

+

+ Transaction ID:{' '} + + {resultTxid} + +

+ + ) + : ( +

+ {(lastResult && lastResult.message) || 'Failed to post memo.'} +

+ )} +
+ + + +
) } diff --git a/psf-memo-client/src/services/new-post.js b/psf-memo-client/src/services/new-post.js index 6250a0e..19c292e 100644 --- a/psf-memo-client/src/services/new-post.js +++ b/psf-memo-client/src/services/new-post.js @@ -5,8 +5,8 @@ This is the testable controller behind the React "New Post" page. It wraps the Memo post behavior (src/services/memo-post.js) and adds page-level concerns: holding the current input, computing the remaining character count, - surfacing validation/length errors, and navigating to the recent feed after a - successful post. + surfacing validation/length errors, showing a success/failure result after + broadcast, and navigating to the recent feed when that result is dismissed. The memoPost and navigate concerns are injected so this module stays free of UI/network concerns; environmentally unsuitable I/O lives behind those small @@ -18,6 +18,7 @@ const MemoPost = require('./memo-post') 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 = {}) { @@ -25,8 +26,11 @@ class NewPostPage extends PageController { this.memoPost = deps.memoPost || null this.menuLinks = deps.menuLinks || [] this.posting = false - this.successPath = RECENT_FEED_PATH + // Navigation is deferred until the result modal is dismissed. + this.successPath = null this.validationCodes = ['memo_validation', 'memo_length'] + this.showResultModal = false + this.lastResult = null // The navigation menu links to the new post page. this.addMenuLink(NEW_POST_PATH) @@ -60,13 +64,45 @@ class NewPostPage extends PageController { } return this.memoPost.post(input) } + + // Block explorer URL for a broadcast transaction. + explorerUrl (txid) { + return NewPostPage.explorerUrl(txid) + } + + // Submit the memo. On broadcast success or failure, open the result modal + // instead of navigating. Validation errors stay on the form. + async submit () { + this.showResultModal = false + this.lastResult = null + const result = await super.submit() + this.lastResult = result + if (result.ok || result.error === 'broadcast') { + this.showResultModal = true + } + return result + } + + // Dismiss the result modal. A successful post then navigates to the feed. + dismissResult () { + const shouldNavigate = Boolean(this.lastResult && this.lastResult.ok) + this.showResultModal = false + if (shouldNavigate) { + this.navigate(RECENT_FEED_PATH) + } + } } 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}` +} module.exports = NewPostPage // mutate4javascript-manifest-begin -// {"version":1,"tested_at":"2026-08-26T12:18:47.139Z","module_hash":"45d772cfe40b8a34018092abbe924c01cbde741c5cbaa737a283ba35214a99ff","functions":[{"id":"func/NewPostPage.constructor","name":"NewPostPage.constructor","line":23,"end_line":33,"hash":"686653c181941b27c19cd8c1f9fa7b3fee50db6e19c1e39d0db79e6bfbe52a81"},{"id":"func/NewPostPage.addMenuLink","name":"NewPostPage.addMenuLink","line":36,"end_line":39,"hash":"bac97164d2d70bfdb946c4d54e67983c43cad093bac558f1d169e1739ca97137"},{"id":"func/NewPostPage.hasMenuLink","name":"NewPostPage.hasMenuLink","line":42,"end_line":44,"hash":"7e1abf5d0833aaf3b3da3a024de9eb2b80da900b2930e832c7e92d77e0e50344"},{"id":"func/NewPostPage.remainingCount","name":"NewPostPage.remainingCount","line":47,"end_line":49,"hash":"521ce4ed841f62099529b327f2245a9e94c09af2f67ed5d91839e52607bcea37"},{"id":"func/NewPostPage._setBusy","name":"NewPostPage._setBusy","line":52,"end_line":54,"hash":"af4c51d75a9bbfc4d8c2566414ee950704d83831ad915ee04eea8bf2fab65a3e"},{"id":"func/NewPostPage._perform","name":"NewPostPage._perform","line":57,"end_line":62,"hash":"e66f893a4913b5d6f3cc4fcc1ad58e21557b4a316f0150050c961e3ece237795"}]} +// {"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"}]} // mutate4javascript-manifest-end diff --git a/psf-memo-client/test/unit/new-post-page.test.js b/psf-memo-client/test/unit/new-post-page.test.js new file mode 100644 index 0000000..d1d6a04 --- /dev/null +++ b/psf-memo-client/test/unit/new-post-page.test.js @@ -0,0 +1,168 @@ +/* + Unit tests for the New Post page controller. + + The page controller wraps the Memo post behavior, exposes a remaining + character count, shows a success/failure result after broadcast, and + navigates to the recent feed when that result is dismissed. +*/ + +'use strict' + +const test = require('node:test') +const assert = require('node:assert/strict') +const NewPostPage = require('../../src/services/new-post') +const MemoPost = require('../../src/services/memo-post') + +const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d' +const SAMPLE_TXID = '05280746adc9611baf0d6ae833c01343f19ef16a7c9434acc682dde129ae2622' + +function makeWallet (address = MY_ADDRESS, txid = SAMPLE_TXID) { + return { + walletInfo: { cashAddress: address }, + broadcasts: [], + async getUtxos () { + return [] + }, + async sendOpReturn (msg, prefix) { + this.broadcasts.push({ msg, prefix }) + if (this.failWith) throw new Error(this.failWith) + return txid + } + } +} + +function makeMemoPost (opts = {}) { + const wallet = makeWallet(opts.address, opts.txid) + if (opts.failWith) wallet.failWith = opts.failWith + const feed = { posts: [], addPost (post) { this.posts.push(post) } } + return { wallet, feed, memoPost: new MemoPost({ wallet, feed }) } +} + +test('the in-flight flag starts false', () => { + const page = new NewPostPage({ navigate: () => {} }) + + assert.equal(page.posting, false) +}) + +test('NEW_POST_PATH and RECENT_FEED_PATH constants', () => { + assert.equal(NewPostPage.NEW_POST_PATH, '/posts/new') + assert.equal(NewPostPage.RECENT_FEED_PATH, '/posts/recent') +}) + +test('explorerUrl builds a bch.loping.net transaction link', () => { + assert.equal( + NewPostPage.explorerUrl(SAMPLE_TXID), + `https://bch.loping.net/tx/${SAMPLE_TXID}` + ) + assert.equal(NewPostPage.explorerUrl(''), '') + const page = new NewPostPage({ navigate: () => {} }) + assert.equal(page.explorerUrl(SAMPLE_TXID), NewPostPage.explorerUrl(SAMPLE_TXID)) +}) + +test('the new post page is linked from the navigation menu', () => { + const page = new NewPostPage({ navigate: () => {} }) + + assert.equal(page.hasMenuLink('/posts/new'), true) +}) + +test('remainingCount returns the full budget for empty input', () => { + const { memoPost } = makeMemoPost() + const page = new NewPostPage({ memoPost, navigate: () => {} }) + + assert.equal(page.remainingCount(), MemoPost.MAX_MEMO_CHARS) +}) + +test('remainingCount subtracts the character length of the input', () => { + const { memoPost } = makeMemoPost() + const page = new NewPostPage({ memoPost, navigate: () => {} }) + page.setInput('hello') + + assert.equal(page.remainingCount(), MemoPost.MAX_MEMO_CHARS - 5) +}) + +test('submit does not navigate until the success modal is dismissed', async () => { + const navigated = [] + const { memoPost } = makeMemoPost() + const page = new NewPostPage({ + memoPost, + navigate: (path) => navigated.push(path) + }) + page.setInput('hello memo') + + const result = await page.submit() + + assert.equal(result.ok, true) + assert.equal(result.txid, SAMPLE_TXID) + assert.equal(page.showResultModal, true) + assert.equal(page.lastResult.txid, SAMPLE_TXID) + assert.deepEqual(navigated, []) + + page.dismissResult() + + assert.equal(page.showResultModal, false) + assert.deepEqual(navigated, [NewPostPage.RECENT_FEED_PATH]) +}) + +test('submit records a validation error for empty input and does not open the modal', async () => { + const navigated = [] + const { memoPost } = makeMemoPost() + const page = new NewPostPage({ + memoPost, + navigate: (path) => navigated.push(path) + }) + page.setInput('') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'memo_validation') + assert.equal(page.showResultModal, false) + assert.deepEqual(navigated, []) +}) + +test('submit records a length error for over-long input and does not open the modal', async () => { + const { memoPost } = makeMemoPost() + const page = new NewPostPage({ memoPost, navigate: () => {} }) + page.setInput('a'.repeat(MemoPost.MAX_MEMO_CHARS + 1)) + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'memo_length') + assert.equal(page.showResultModal, false) +}) + +test('a failed broadcast opens the failure modal and stays on the page after dismiss', async () => { + const navigated = [] + const { memoPost } = makeMemoPost({ failWith: 'Insufficient balance' }) + const page = new NewPostPage({ + memoPost, + navigate: (path) => navigated.push(path) + }) + page.setInput('hello memo') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'broadcast') + assert.match(result.message, /Insufficient balance/) + assert.equal(page.showResultModal, true) + assert.deepEqual(navigated, []) + + page.dismissResult() + + assert.equal(page.showResultModal, false) + assert.deepEqual(navigated, []) +}) + +test('submit records a broadcast error when no memo post handler is injected', async () => { + const page = new NewPostPage({ navigate: () => {} }) + page.setInput('hello memo') + + const result = await page.submit() + + assert.equal(result.ok, false) + assert.equal(result.error, 'broadcast') + assert.match(result.message, /New post requires a memo post handler/) + assert.equal(page.showResultModal, true) +}) diff --git a/test/unit/new-post.test.js b/test/unit/new-post.test.js index dd4a0dd..21db371 100644 --- a/test/unit/new-post.test.js +++ b/test/unit/new-post.test.js @@ -3,7 +3,7 @@ Expresses the observable behavior described by specs/memo-new.feature: - posting a valid memo broadcasts an OP_RETURN with the Memo post prefix and - navigates the user to the recent feed. + navigates the user to the recent feed after the success modal is dismissed. - an empty memo is rejected with a validation error; nothing is broadcast. - an over-long memo is rejected with a length error; nothing is broadcast. - the character counter counts down from the memo limit. @@ -79,7 +79,7 @@ registerPageSubmitTests({ validationCode: 'memo_validation', lengthCode: 'memo_length', MAX, - successPath: '/posts/recent', + successPath: null, assertBroadcastMsg: (broadcast) => assert.equal(broadcast.msg, 'hello memo'), assertStore: (store) => assert.equal(store.posts[0].text, 'hello memo'), assertStoreEmpty: (store) => assert.equal(store.posts.length, 0) @@ -110,6 +110,7 @@ test('a failed broadcast surfaces a different real error message', async () => { assert.equal(result.ok, false) assert.match(page.broadcastError, /Insufficient balance/) + assert.equal(page.showResultModal, true) }) test('a broadcast failure with an empty message falls back to a string form', async () => { @@ -126,4 +127,32 @@ test('a broadcast failure with an empty message falls back to a string form', as // The real (string) error is surfaced even though the message was empty. assert.equal(typeof page.broadcastError, 'string') assert.ok(page.broadcastError.length > 0) + assert.equal(page.showResultModal, true) +}) + +test('dismissing a successful result navigates to the recent feed', async () => { + const { page, navigations } = build() + page.setInput('hello memo') + + const result = await page.submit() + + assert.equal(result.ok, true) + assert.equal(page.showResultModal, true) + assert.deepEqual(navigations, []) + + page.dismissResult() + + assert.equal(page.showResultModal, false) + assert.deepEqual(navigations, [NewPostPage.RECENT_FEED_PATH]) +}) + +test('dismissing a failed result stays on the new post page', async () => { + const { wallet, page, navigations } = build() + wallet.failWith = 'BCH UTXO list is empty' + page.setInput('hello memo') + + await page.submit() + page.dismissResult() + + assert.deepEqual(navigations, []) })