New posts display TXID after broadcast

This commit is contained in:
Chris Troutner
2026-09-13 06:42:14 -07:00
parent d2c0bedab3
commit 8244e11ec4
6 changed files with 360 additions and 17 deletions
@@ -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$/,
+6 -1
View File
@@ -14,6 +14,9 @@ Feature: New Post Page
When I type a memo with the text "<message>"
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 "<message>"
@@ -66,7 +69,9 @@ Feature: New Post Page
When I type a memo with the text "<message>"
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 "<broadcast_error>"
Then the new post page shows a failure modal containing "<broadcast_error>"
Then I remain on the path /posts/new
When I dismiss the result modal
Then I remain on the path /posts/new
Examples:
@@ -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 (
<Container>
<Row className='justify-content-center'>
@@ -72,6 +89,7 @@ function NewPost (props) {
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder='Write your Memo here...'
disabled={posting}
/>
</Form.Group>
@@ -87,6 +105,43 @@ function NewPost (props) {
</Form>
</Col>
</Row>
<Modal show={showResultModal} onHide={handleDismissResult} centered>
<Modal.Header closeButton>
<Modal.Title>
{lastResult && lastResult.ok ? 'Post published' : 'Post failed'}
</Modal.Title>
</Modal.Header>
<Modal.Body>
{lastResult && lastResult.ok
? (
<>
<p>Your post was broadcast to the Bitcoin Cash network.</p>
<p className='new-post-txid mb-0'>
Transaction ID:{' '}
<a
href={explorerUrl}
target='_blank'
rel='noreferrer'
style={{ wordBreak: 'break-all' }}
>
{resultTxid}
</a>
</p>
</>
)
: (
<p className='new-post-error mb-0'>
{(lastResult && lastResult.message) || 'Failed to post memo.'}
</p>
)}
</Modal.Body>
<Modal.Footer>
<Button variant='primary' onClick={handleDismissResult}>
Close
</Button>
</Modal.Footer>
</Modal>
</Container>
)
}
+40 -4
View File
@@ -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
@@ -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)
})
+31 -2
View File
@@ -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, [])
})