Files
psf-memo/psf-memo-client/src/services/like-tip-page.js
T
Chris Troutner e071881ea0 Harden like result modal: kill mutation survivors and DRY test setup
Add unit coverage for the LikeTipPage initial state, deps seeding, the
dust-limit boundary, and empty/null tip parsing, and assert exact
validation/broadcast error messages. Add the NewPostPage initial
result-modal state assertion. Refresh the mutate4javascript footer
manifests for the touched services and the soft Gherkin
acceptance-mutation manifest for the like-result feature.

By architect.
2026-09-16 12:29:53 -07:00

156 lines
7.0 KiB
JavaScript

/*
Like / Tip page behavior: open a modal for a post, validate an optional tip,
and submit a Memo like (0x6d04) with or without a tip.
This is the testable controller behind the React like/tip modal. It wraps
the Memo like behavior (src/services/memo-like.js) and adds page-level
concerns: holding the target post txid, holding the tip input as a string,
surfacing validation/dust/maximum/balance errors, and closing the modal on
success or cancellation.
The memoLike and modal state concerns are injected so this module stays free
of UI/network concerns; environmentally unsuitable I/O lives behind those
small adapter boundaries.
*/
const PageController = require('./page-controller')
const { BLOCK_EXPLORER_TX_BASE, blockExplorerTxUrl } = require('./block-explorer')
const SUCCESS_MESSAGE = 'Your like was broadcast to the Bitcoin Cash network.'
class LikeTipPage extends PageController {
constructor (deps = {}) {
super(deps)
this.memoLike = deps.memoLike || null
this.tipping = false
this.modalOpen = false
this.postTxid = deps.postTxid || null
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
// spendable balance to cover fees. Returns a result object.
open (postTxid, authorAddress) {
this.postTxid = postTxid
this.authorAddress = authorAddress
this.modalOpen = true
this.showResultModal = false
this.lastResult = null
this.submitError = null
this.broadcastError = null
if (!this.memoLike) {
this.submitError = 'like_validation'
this.broadcastError = 'Like requires a memo like handler.'
return { ok: false, error: this.submitError, message: this.broadcastError }
}
try {
const spendable = this.memoLike.getSpendableSats()
if (spendable < this.memoLike.dustLimit) {
const err = new Error('add BCH to your wallet before liking a post.')
err.code = 'like_empty_balance'
throw err
}
} catch (err) {
return this._handleSubmitFailure(err)
}
return { ok: true }
}
// Close the modal and reset input/errors.
close () {
this.modalOpen = false
this.submitError = null
this.broadcastError = null
this.input = ''
}
// Set the tip amount as a raw string. The string form is validated on submit
// so non-numeric or decimal input can be rejected.
setTip (tipStr) {
this.setInput(tipStr)
}
// Set the in-flight tipping flag.
_setBusy (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.
_parseTip (input) {
if (input === '' || input === null || input === undefined) return 0
if (!/^\d+$/.test(String(input))) {
const err = new Error('Tip must be a valid number of satoshis.')
err.code = 'like_validation'
throw err
}
return parseInt(input, 10)
}
// Run the memo like action for the current post and tip.
async _perform (input) {
if (!this.memoLike) {
throw new Error('Like requires a memo like handler.')
}
const tipSats = this._parseTip(input)
return this.memoLike.like(this.postTxid, tipSats, this.authorAddress)
}
// Surface the real error message for every failure, including local
// validation failures, so the like/tip modal can display it.
_handleSubmitFailure (err) {
this.broadcastError = err.message || String(err)
return super._handleSubmitFailure(err)
}
}
module.exports = LikeTipPage
LikeTipPage.EXPLORER_TX_BASE = BLOCK_EXPLORER_TX_BASE
LikeTipPage.SUCCESS_MESSAGE = SUCCESS_MESSAGE
LikeTipPage.explorerUrl = blockExplorerTxUrl
// mutate4javascript-manifest-begin
// {"version":1,"tested_at":"2026-09-16T19:21:51.249Z","module_hash":"df3f5a3cd43fba8d3b7267561b21c84b68b964a958b6b77f0fa4b12f3afffc8d","functions":[{"id":"func/LikeTipPage.constructor","name":"LikeTipPage.constructor","line":22,"end_line":35,"hash":"020fe0af9dd6885fb83ad2edc0e94ef5d28661eec2a138fa5576743014341124"},{"id":"func/LikeTipPage.open","name":"LikeTipPage.open","line":39,"end_line":66,"hash":"0bfb69e7e8f6d9fc8ec2c923c20578215e8765088c514f8398461e64108aaaff"},{"id":"func/LikeTipPage.close","name":"LikeTipPage.close","line":69,"end_line":74,"hash":"673843dc179c1a325e224d6ad235f945d14b7efbceefb26f1b1f69dccfa4e535"},{"id":"func/LikeTipPage.setTip","name":"LikeTipPage.setTip","line":78,"end_line":80,"hash":"cfa38ba8b9f799839393de98e83d5512699327d99dbcf3de11d322be3a027323"},{"id":"func/LikeTipPage._setBusy","name":"LikeTipPage._setBusy","line":83,"end_line":85,"hash":"8fe4b97a14cdbd3849bd6bf493738a2caf50f36002de1c96603290b98c83e511"},{"id":"func/LikeTipPage.submit","name":"LikeTipPage.submit","line":90,"end_line":100,"hash":"d7a255241f316400a9c1f30b057ae7c63e27421d76432c8e02ecc88121319982"},{"id":"func/LikeTipPage.getBroadcastMessage","name":"LikeTipPage.getBroadcastMessage","line":103,"end_line":106,"hash":"800d20ba3a50dcffcee063bc5cd6422b548713520530b3d159cf1980310000b2"},{"id":"func/LikeTipPage.explorerUrl","name":"LikeTipPage.explorerUrl","line":109,"end_line":111,"hash":"7822bf8055c5fc45eaa46919b8665c8954def38f92bed94b091796a6a9b5381d"},{"id":"func/LikeTipPage.dismissResult","name":"LikeTipPage.dismissResult","line":114,"end_line":117,"hash":"96d1421749b8e3a3c295ad071d4bd921d17edd7c5e8824b1b3c3196b009cfcab"},{"id":"func/LikeTipPage._parseTip","name":"LikeTipPage._parseTip","line":120,"end_line":128,"hash":"7db17db65fe02da8db7d4658aa2fc38d52f61754c6e3a92cca5db6ebcf8eda5a"},{"id":"func/LikeTipPage._perform","name":"LikeTipPage._perform","line":131,"end_line":137,"hash":"f6af26bbf3965455c3436dd1da28bf22e27b65d28d4e02a9fe423a909efca030"},{"id":"func/LikeTipPage._handleSubmitFailure","name":"LikeTipPage._handleSubmitFailure","line":141,"end_line":144,"hash":"a66731eb7050a3c87ff7d228de3661facb9c971e7611d7953f86ba1b88bb1110"}]}
// mutate4javascript-manifest-end