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', name: 'page shows validation/length error',
pattern: /^the (?:app|new post page) shows a (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 type a memo with the text "<message>"
When I click the post button When I click the post button
Then the app broadcasts an OP_RETURN transaction with the Memo post prefix 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 I navigate to the path /posts/recent
Then the feed shows a new post from my address with the text "<message>" 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 type a memo with the text "<message>"
When I click the post button When I click the post button
Then the app attempts to broadcast an OP_RETURN transaction with the Memo post prefix 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 Then I remain on the path /posts/new
Examples: Examples:
@@ -1,12 +1,13 @@
/* /*
New Post view: compose and broadcast a Memo post, with a character counter 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 that counts down from the memo limit. After broadcast, a modal reports
recent feed. success (txid + explorer link) or failure. Dismissing a successful result
navigates to the recent feed.
*/ */
// Global npm libraries // Global npm libraries
import React, { useState } from 'react' import React, { useRef, useState } from 'react'
import { Container, Row, Col, Form, Button } from 'react-bootstrap' import { Container, Row, Col, Form, Button, Modal } from 'react-bootstrap'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
// Local libraries // Local libraries
@@ -21,8 +22,13 @@ function NewPost (props) {
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [posting, setPosting] = useState(false) 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 remaining = maxChars - input.length
const resultTxid = lastResult && lastResult.ok ? lastResult.txid : ''
const explorerUrl = NewPostPage.explorerUrl(resultTxid)
async function handleSubmit (event) { async function handleSubmit (event) {
event.preventDefault() event.preventDefault()
@@ -32,28 +38,39 @@ function NewPost (props) {
try { try {
const memoPost = new MemoPost({ wallet: appData?.wallet }) const memoPost = new MemoPost({ wallet: appData?.wallet })
const page = new NewPostPage({ memoPost, navigate }) const page = new NewPostPage({ memoPost, navigate })
pageRef.current = page
page.setInput(input) page.setInput(input)
const result = await page.submit() const result = await page.submit()
setLastResult(result)
if (!result.ok) { if (!result.ok) {
if (result.error === 'memo_length') { if (result.error === 'memo_length') {
setErr(`Memo is too long. Maximum is ${maxChars} characters.`) setErr(`Memo is too long. Maximum is ${maxChars} characters.`)
} else if (result.error === 'memo_validation') { } else if (result.error === 'memo_validation') {
setErr('Memo must not be empty.') 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) { } catch (submitErr) {
setErr(submitErr.message) const result = { ok: false, error: 'broadcast', message: submitErr.message }
setLastResult(result)
setShowResultModal(true)
} finally { } finally {
setPosting(false) setPosting(false)
} }
} }
function handleDismissResult () {
if (pageRef.current) {
pageRef.current.dismissResult()
} else if (lastResult && lastResult.ok) {
navigate(NewPostPage.RECENT_FEED_PATH)
}
setShowResultModal(false)
}
return ( return (
<Container> <Container>
<Row className='justify-content-center'> <Row className='justify-content-center'>
@@ -72,6 +89,7 @@ function NewPost (props) {
value={input} value={input}
onChange={(e) => setInput(e.target.value)} onChange={(e) => setInput(e.target.value)}
placeholder='Write your Memo here...' placeholder='Write your Memo here...'
disabled={posting}
/> />
</Form.Group> </Form.Group>
@@ -87,6 +105,43 @@ function NewPost (props) {
</Form> </Form>
</Col> </Col>
</Row> </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> </Container>
) )
} }
+40 -4
View File
@@ -5,8 +5,8 @@
This is the testable controller behind the React "New Post" page. It wraps 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 the Memo post behavior (src/services/memo-post.js) and adds page-level
concerns: holding the current input, computing the remaining character count, concerns: holding the current input, computing the remaining character count,
surfacing validation/length errors, and navigating to the recent feed after a surfacing validation/length errors, showing a success/failure result after
successful post. 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 The memoPost and navigate concerns are injected so this module stays free of
UI/network concerns; environmentally unsuitable I/O lives behind those small 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 NEW_POST_PATH = '/posts/new'
const RECENT_FEED_PATH = '/posts/recent' const RECENT_FEED_PATH = '/posts/recent'
const EXPLORER_TX_BASE = 'https://bch.loping.net/tx'
class NewPostPage extends PageController { class NewPostPage extends PageController {
constructor (deps = {}) { constructor (deps = {}) {
@@ -25,8 +26,11 @@ class NewPostPage extends PageController {
this.memoPost = deps.memoPost || null this.memoPost = deps.memoPost || null
this.menuLinks = deps.menuLinks || [] this.menuLinks = deps.menuLinks || []
this.posting = false 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.validationCodes = ['memo_validation', 'memo_length']
this.showResultModal = false
this.lastResult = null
// The navigation menu links to the new post page. // The navigation menu links to the new post page.
this.addMenuLink(NEW_POST_PATH) this.addMenuLink(NEW_POST_PATH)
@@ -60,13 +64,45 @@ class NewPostPage extends PageController {
} }
return this.memoPost.post(input) 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.NEW_POST_PATH = NEW_POST_PATH
NewPostPage.RECENT_FEED_PATH = RECENT_FEED_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 module.exports = NewPostPage
// mutate4javascript-manifest-begin // 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 // 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: Expresses the observable behavior described by specs/memo-new.feature:
- posting a valid memo broadcasts an OP_RETURN with the Memo post prefix and - 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 empty memo is rejected with a validation error; nothing is broadcast.
- an over-long memo is rejected with a length 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. - the character counter counts down from the memo limit.
@@ -79,7 +79,7 @@ registerPageSubmitTests({
validationCode: 'memo_validation', validationCode: 'memo_validation',
lengthCode: 'memo_length', lengthCode: 'memo_length',
MAX, MAX,
successPath: '/posts/recent', successPath: null,
assertBroadcastMsg: (broadcast) => assert.equal(broadcast.msg, 'hello memo'), assertBroadcastMsg: (broadcast) => assert.equal(broadcast.msg, 'hello memo'),
assertStore: (store) => assert.equal(store.posts[0].text, 'hello memo'), assertStore: (store) => assert.equal(store.posts[0].text, 'hello memo'),
assertStoreEmpty: (store) => assert.equal(store.posts.length, 0) 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.equal(result.ok, false)
assert.match(page.broadcastError, /Insufficient balance/) 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 () => { 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. // The real (string) error is surfaced even though the message was empty.
assert.equal(typeof page.broadcastError, 'string') assert.equal(typeof page.broadcastError, 'string')
assert.ok(page.broadcastError.length > 0) 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, [])
}) })