mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-client.git
synced 2026-09-21 16:52:02 -07:00
Implement Like / Tip a Memo
Adds the Memo like action (0x6d04) with optional author tip, a like/tip page controller, heart icon + modal UI, and acceptance handlers for specs/like-tip-memo.feature. By coder.
This commit is contained in:
+237
-3
@@ -26,10 +26,16 @@ const ReplyThreadPage = require('../../src/services/reply-thread-page')
|
||||
const MemoSetName = require('../../src/services/memo-set-name')
|
||||
const SetNamePage = require('../../src/services/set-name-page')
|
||||
const AccountPage = require('../../src/services/account-page')
|
||||
const MemoLike = require('../../src/services/memo-like')
|
||||
const LikeTipPage = require('../../src/services/like-tip-page')
|
||||
|
||||
const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
|
||||
const MEMO_REPLY_PREFIX = MemoReply.MEMO_REPLY_PREFIX
|
||||
const MEMO_SET_NAME_PREFIX = MemoSetName.MEMO_SET_NAME_PREFIX
|
||||
const MEMO_LIKE_PREFIX = MemoLike.MEMO_LIKE_PREFIX
|
||||
|
||||
// Default author address used by Gherkin steps that refer to "the author address".
|
||||
const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc'
|
||||
|
||||
// A fake wallet exposing the minimal-slp-wallet adapter surface the app uses.
|
||||
function makeWallet (address) {
|
||||
@@ -40,9 +46,9 @@ function makeWallet (address) {
|
||||
getUtxos: async function () {
|
||||
return this.utxos
|
||||
},
|
||||
sendOpReturn: async function (msg, prefix) {
|
||||
sendOpReturn: async function (msg, prefix, bchOutput = []) {
|
||||
// Record the broadcast attempt, then fail if configured to do so.
|
||||
this.broadcasts.push({ msg, prefix })
|
||||
this.broadcasts.push({ msg, prefix, bchOutput })
|
||||
if (this.failWith) throw new Error(this.failWith)
|
||||
return 'aa'.repeat(32)
|
||||
}
|
||||
@@ -86,14 +92,18 @@ function createWorld () {
|
||||
const memoPost = new MemoPost({ wallet, feed })
|
||||
const thread = makeThread()
|
||||
const memoReply = new MemoReply({ wallet, thread })
|
||||
const memoLike = new MemoLike({ wallet, feed })
|
||||
|
||||
const world = {
|
||||
wallet,
|
||||
feed,
|
||||
thread,
|
||||
memoPost,
|
||||
memoReply,
|
||||
memoLike,
|
||||
currentPath: null,
|
||||
menuOpen: false
|
||||
menuOpen: false,
|
||||
likedTxids: new Set()
|
||||
}
|
||||
|
||||
// The New Post Page controller wraps the memo post behavior. Its navigate
|
||||
@@ -111,6 +121,9 @@ function createWorld () {
|
||||
navigate: () => {}
|
||||
})
|
||||
|
||||
// The Like / Tip Page controller wraps the memo like behavior.
|
||||
world.likeTipPage = new LikeTipPage({ memoLike })
|
||||
|
||||
// The Set Name Page and Account Page controllers share a profile store so
|
||||
// a name set on one page is visible on the other.
|
||||
const profiles = makeProfiles()
|
||||
@@ -136,6 +149,24 @@ function decodeReplyPayload (raw) {
|
||||
return { parentTxid, text }
|
||||
}
|
||||
|
||||
// Decode a raw like payload back into the liked post txid (hex).
|
||||
function decodeLikeTxid (raw) {
|
||||
return Buffer.from(raw).toString('hex')
|
||||
}
|
||||
|
||||
// Resolve a literal value or a <parameter> placeholder from the example store.
|
||||
function resolveParam (value, example) {
|
||||
const match = /^<([A-Za-z0-9_]+)>$/.exec(String(value).trim())
|
||||
if (match) {
|
||||
const param = match[1]
|
||||
if (!(param in example)) {
|
||||
throw new Error(`Missing example value for "${param}"`)
|
||||
}
|
||||
return example[param]
|
||||
}
|
||||
return String(value).trim()
|
||||
}
|
||||
|
||||
// Handler registry. Each entry: { pattern, run }.
|
||||
// run receives (match, exampleStore, world, step).
|
||||
const handlers = [
|
||||
@@ -562,6 +593,209 @@ const handlers = [
|
||||
throw new Error('Account page does not show a Set Name button.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'wallet has spendable balance',
|
||||
pattern: /^the wallet has a spendable balance of (.+) sats$/,
|
||||
run (m, example, world) {
|
||||
const balance = parseInt(resolveParam(m[1], example), 10)
|
||||
if (Number.isNaN(balance)) {
|
||||
throw new Error(`Invalid balance value "${m[1]}"`)
|
||||
}
|
||||
world.wallet.utxos = [{ txid: 'utxo-for-balance', value: balance }]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'post with txid authored by author address',
|
||||
pattern: /^a post with the txid (.+) authored by the author address$/,
|
||||
run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
const post = {
|
||||
txid,
|
||||
addr: AUTHOR_ADDRESS,
|
||||
address: AUTHOR_ADDRESS,
|
||||
text: 'A sample post',
|
||||
likeCount: 0
|
||||
}
|
||||
world.feed.addPost(post)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'post with txid authored by my address',
|
||||
pattern: /^a post with the txid (.+) authored by my address$/,
|
||||
run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
const myAddress = world.wallet.walletInfo.cashAddress
|
||||
const post = {
|
||||
txid,
|
||||
addr: myAddress,
|
||||
address: myAddress,
|
||||
text: 'My own post',
|
||||
likeCount: 0
|
||||
}
|
||||
world.feed.addPost(post)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'click heart icon on post',
|
||||
pattern: /^I click the heart icon on the post with txid (.+)$/,
|
||||
run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
const post = world.feed.posts.find((p) => p.txid === txid)
|
||||
const authorAddress = post ? post.addr : AUTHOR_ADDRESS
|
||||
world.likeTipPage.open(txid, authorAddress)
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'like/tip modal opens for post',
|
||||
pattern: /^a like\/tip modal opens for the post with txid (.+)$/,
|
||||
run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
if (!world.likeTipPage.modalOpen) {
|
||||
throw new Error('Expected like/tip modal to be open.')
|
||||
}
|
||||
if (world.likeTipPage.postTxid !== txid) {
|
||||
throw new Error(`Expected like/tip modal for ${txid}, but got ${world.likeTipPage.postTxid}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'submit like without tip',
|
||||
pattern: /^I submit the like without a tip$/,
|
||||
async run (m, example, world) {
|
||||
world.likeTipPage.setTip('')
|
||||
const result = await world.likeTipPage.submit()
|
||||
if (result.ok) {
|
||||
world.likedTxids.add(world.likeTipPage.postTxid)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'enter tip',
|
||||
pattern: /^I enter a tip of (.+)$/,
|
||||
run (m, example, world) {
|
||||
world.likeTipPage.setTip(resolveParam(m[1], example))
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'submit like',
|
||||
pattern: /^I submit the like$/,
|
||||
async run (m, example, world) {
|
||||
const result = await world.likeTipPage.submit()
|
||||
if (result.ok) {
|
||||
world.likedTxids.add(world.likeTipPage.postTxid)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'broadcasts OP_RETURN with Memo like prefix',
|
||||
pattern: /^the wallet broadcasts an OP_RETURN transaction with the Memo like prefix and the post txid (.+)$/,
|
||||
run (m, example, world) {
|
||||
const txid = resolveParam(m[1], example)
|
||||
const broadcasts = world.wallet.broadcasts
|
||||
if (!broadcasts.length) {
|
||||
throw new Error('No OP_RETURN transaction was broadcast.')
|
||||
}
|
||||
const last = broadcasts[broadcasts.length - 1]
|
||||
if (last.prefix !== MEMO_LIKE_PREFIX) {
|
||||
throw new Error(`Expected Memo like prefix ${MEMO_LIKE_PREFIX}, got "${last.prefix}".`)
|
||||
}
|
||||
if (decodeLikeTxid(last.msg) !== txid) {
|
||||
throw new Error(`Broadcast liked txid did not match ${txid}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'wallet sends no tip',
|
||||
pattern: /^the wallet sends no tip$/,
|
||||
run (m, example, world) {
|
||||
const broadcasts = world.wallet.broadcasts
|
||||
if (!broadcasts.length) {
|
||||
throw new Error('No transaction was broadcast.')
|
||||
}
|
||||
const last = broadcasts[broadcasts.length - 1]
|
||||
if (!Array.isArray(last.bchOutput) || last.bchOutput.length !== 0) {
|
||||
throw new Error('Expected no tip output, but one was present.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'wallet sends tip to author',
|
||||
pattern: /^the wallet sends a tip of (.+) to the author address$/,
|
||||
run (m, example, world) {
|
||||
const expectedTip = parseInt(resolveParam(m[1], example), 10)
|
||||
if (Number.isNaN(expectedTip)) {
|
||||
throw new Error(`Invalid tip value "${m[1]}"`)
|
||||
}
|
||||
const broadcasts = world.wallet.broadcasts
|
||||
if (!broadcasts.length) {
|
||||
throw new Error('No transaction was broadcast.')
|
||||
}
|
||||
const last = broadcasts[broadcasts.length - 1]
|
||||
if (!Array.isArray(last.bchOutput) || last.bchOutput.length === 0) {
|
||||
throw new Error('Expected a tip output, but none was present.')
|
||||
}
|
||||
const tipOutput = last.bchOutput[0]
|
||||
if (tipOutput.amountSat !== expectedTip) {
|
||||
throw new Error(`Expected tip ${expectedTip} sats, got ${tipOutput.amountSat}.`)
|
||||
}
|
||||
const post = world.feed.posts.find((p) => p.txid === world.likeTipPage.postTxid)
|
||||
const expectedAddress = post ? post.addr : AUTHOR_ADDRESS
|
||||
if (tipOutput.address !== expectedAddress) {
|
||||
throw new Error(`Expected tip to ${expectedAddress}, got ${tipOutput.address}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'like count increases by one',
|
||||
pattern: /^the like count on the post increases by one$/,
|
||||
run (m, example, world) {
|
||||
const postTxid = world.likeTipPage.postTxid
|
||||
const post = world.feed.posts.find((p) => p.txid === postTxid)
|
||||
if (!post) {
|
||||
throw new Error(`Post ${postTxid} not found in feed.`)
|
||||
}
|
||||
if (post.likeCount !== 1) {
|
||||
throw new Error(`Expected like count to be 1, got ${post.likeCount}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'heart icon shows as filled',
|
||||
pattern: /^the heart icon on the post shows as filled$/,
|
||||
run (m, example, world) {
|
||||
const postTxid = world.likeTipPage.postTxid
|
||||
if (!world.likedTxids.has(postTxid)) {
|
||||
throw new Error(`Expected heart icon to be filled for ${postTxid}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'like/tip modal shows error containing text',
|
||||
pattern: /^the like\/tip modal shows an error containing "(.+)"$/,
|
||||
run (m, example, world) {
|
||||
const expected = m[1]
|
||||
const actual = world.likeTipPage.broadcastError || ''
|
||||
if (!actual.includes(expected)) {
|
||||
throw new Error(`Expected an error containing "${expected}", got "${actual}".`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'click cancel button',
|
||||
pattern: /^I click the cancel button$/,
|
||||
run (m, example, world) {
|
||||
world.likeTipPage.close()
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'like/tip modal closes',
|
||||
pattern: /^the like\/tip modal closes$/,
|
||||
run (m, example, world) {
|
||||
if (world.likeTipPage.modalOpen) {
|
||||
throw new Error('Expected like/tip modal to be closed.')
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -131,6 +131,7 @@ function RecentPosts (props) {
|
||||
key={post.txid}
|
||||
post={post}
|
||||
profiles={profiles}
|
||||
wallet={appData?.wallet}
|
||||
onReplyClick={() => openThread(post.txid)}
|
||||
showFooterMeta
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
Like button for a post (heart icon + count).
|
||||
|
||||
The icon is filled when the post has been liked in the current session and
|
||||
outlined otherwise. The count is displayed next to the icon.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||
import { faHeart as faHeartSolid } from '@fortawesome/free-solid-svg-icons'
|
||||
import { faHeart as faHeartRegular } from '@fortawesome/free-regular-svg-icons'
|
||||
|
||||
import './post-feed.css'
|
||||
|
||||
function LikeButton ({ count = 0, liked = false, onClick }) {
|
||||
const label = count === 1 ? '1 like' : `${count} likes`
|
||||
const icon = liked ? faHeartSolid : faHeartRegular
|
||||
const className = [
|
||||
'post-like-button',
|
||||
liked ? 'post-like-button-liked' : ''
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className={className}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
>
|
||||
<FontAwesomeIcon icon={icon} className='post-like-button-icon' />
|
||||
<span className='post-like-button-count'>{count}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default LikeButton
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
Like / Tip modal for a post.
|
||||
|
||||
Lets the user submit a Memo like (0x6d04) with an optional satoshi tip to the
|
||||
post author. The wallet and target post are injected through props. Errors
|
||||
from validation, dust/maximum/balance checks, and broadcast failures are
|
||||
surfaced in the modal body.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Modal, Form, Button } from 'react-bootstrap'
|
||||
|
||||
import MemoLike from '../../services/memo-like'
|
||||
import LikeTipPage from '../../services/like-tip-page'
|
||||
import { getDisplayName } from './post-display'
|
||||
import './post-feed.css'
|
||||
|
||||
function formatError (result) {
|
||||
if (!result || result.ok) return ''
|
||||
return result.message || ''
|
||||
}
|
||||
|
||||
function LikeTipModal ({ show, post, wallet, profiles = {}, onHide, onSuccess }) {
|
||||
const [tip, setTip] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const displayName = post ? getDisplayName(post.addr, profiles) : ''
|
||||
|
||||
// Reset state whenever the modal is shown and check the wallet balance.
|
||||
useEffect(() => {
|
||||
if (!show || !post || !wallet) {
|
||||
setTip('')
|
||||
setError('')
|
||||
setSubmitting(false)
|
||||
return
|
||||
}
|
||||
|
||||
setTip('')
|
||||
setError('')
|
||||
setSubmitting(false)
|
||||
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
const page = new LikeTipPage({ memoLike })
|
||||
const result = page.open(post.txid, post.addr)
|
||||
if (!result.ok) {
|
||||
setError(formatError(result))
|
||||
}
|
||||
}, [show, post, wallet])
|
||||
|
||||
async function handleSubmit () {
|
||||
if (!post || !wallet) return
|
||||
setError('')
|
||||
setSubmitting(true)
|
||||
|
||||
try {
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
const page = new LikeTipPage({ memoLike })
|
||||
page.open(post.txid, post.addr)
|
||||
page.setTip(tip)
|
||||
|
||||
const result = await page.submit()
|
||||
if (result.ok) {
|
||||
setTip('')
|
||||
if (typeof onSuccess === 'function') {
|
||||
onSuccess()
|
||||
}
|
||||
} else {
|
||||
setError(formatError(result))
|
||||
}
|
||||
} catch (submitErr) {
|
||||
setError(submitErr.message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
function handleCancel () {
|
||||
setTip('')
|
||||
setError('')
|
||||
onHide()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal show={show} onHide={handleCancel} centered>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Like / Tip</Modal.Title>
|
||||
</Modal.Header>
|
||||
|
||||
<Modal.Body>
|
||||
{post && (
|
||||
<p className='like-tip-modal-target'>
|
||||
Like the post by <strong>{displayName}</strong>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Form onSubmit={(e) => { e.preventDefault(); handleSubmit() }}>
|
||||
<Form.Group controlId='like-tip-amount' className='mb-3'>
|
||||
<Form.Label>Tip (satoshis, optional)</Form.Label>
|
||||
<Form.Control
|
||||
type='number'
|
||||
min='0'
|
||||
step='1'
|
||||
placeholder='0'
|
||||
value={tip}
|
||||
onChange={(e) => setTip(e.target.value)}
|
||||
disabled={submitting || !!error}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
|
||||
{error && (
|
||||
<p className='like-tip-modal-error text-danger'>{error}</p>
|
||||
)}
|
||||
</Modal.Body>
|
||||
|
||||
<Modal.Footer>
|
||||
<Button variant='secondary' onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || !!error || !post || !wallet}
|
||||
>
|
||||
{submitting ? 'Liking...' : 'Like'}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default LikeTipModal
|
||||
@@ -2,12 +2,14 @@
|
||||
Instagram-style post card for feed and thread views.
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import AppUtil from '../../util'
|
||||
import PostReplyCount from '../post-reply-count'
|
||||
import PostThreadAvatar from '../post-thread-modal/post-thread-avatar'
|
||||
import LikeButton from './like-button'
|
||||
import LikeTipModal from './like-tip-modal'
|
||||
import {
|
||||
formatRelativeSeen,
|
||||
getDisplayName,
|
||||
@@ -21,14 +23,20 @@ function PostFeedItem ({
|
||||
post,
|
||||
profile = {},
|
||||
profiles = {},
|
||||
wallet,
|
||||
onReplyClick,
|
||||
showRepliedLabel = false,
|
||||
showFooterMeta = false,
|
||||
showReplyCount = true,
|
||||
showLikeButton = true,
|
||||
embedded = false
|
||||
}) {
|
||||
if (!post) return null
|
||||
|
||||
const [liked, setLiked] = useState(false)
|
||||
const [likeCount, setLikeCount] = useState(post.likeCount || 0)
|
||||
const [showLikeModal, setShowLikeModal] = useState(false)
|
||||
|
||||
const displayName = getDisplayName(post.addr, profiles)
|
||||
|
||||
const hasCustomName = Boolean(
|
||||
@@ -46,6 +54,20 @@ function PostFeedItem ({
|
||||
appUtil.copyToClipboard(post.txid)
|
||||
}
|
||||
|
||||
const handleLikeClick = () => {
|
||||
setShowLikeModal(true)
|
||||
}
|
||||
|
||||
const handleLikeSuccess = () => {
|
||||
setLiked(true)
|
||||
setLikeCount((count) => count + 1)
|
||||
setShowLikeModal(false)
|
||||
}
|
||||
|
||||
const handleLikeModalHide = () => {
|
||||
setShowLikeModal(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Wrapper
|
||||
className={[
|
||||
@@ -117,12 +139,21 @@ function PostFeedItem ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{showReplyCount && (
|
||||
{(showReplyCount || showLikeButton) && (
|
||||
<div className='posts-feed-item-actions'>
|
||||
<PostReplyCount
|
||||
count={post.replyCount ?? 0}
|
||||
onClick={onReplyClick}
|
||||
/>
|
||||
{showLikeButton && (
|
||||
<LikeButton
|
||||
count={likeCount}
|
||||
liked={liked}
|
||||
onClick={handleLikeClick}
|
||||
/>
|
||||
)}
|
||||
{showReplyCount && (
|
||||
<PostReplyCount
|
||||
count={post.replyCount ?? 0}
|
||||
onClick={onReplyClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -147,6 +178,15 @@ function PostFeedItem ({
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
|
||||
<LikeTipModal
|
||||
show={showLikeModal}
|
||||
post={post}
|
||||
wallet={wallet}
|
||||
profiles={profiles}
|
||||
onHide={handleLikeModalHide}
|
||||
onSuccess={handleLikeSuccess}
|
||||
/>
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -603,3 +603,62 @@
|
||||
padding: 0 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.post-like-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
|
||||
min-height: 32px;
|
||||
padding: 4px 0;
|
||||
|
||||
color: var(--ig-text);
|
||||
background: transparent;
|
||||
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 18px;
|
||||
text-decoration: none;
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
transition:
|
||||
opacity 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
.post-like-button:hover {
|
||||
color: var(--ig-danger);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.post-like-button-liked,
|
||||
.post-like-button-liked:hover {
|
||||
color: var(--ig-danger);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.post-like-button-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.post-like-button-count {
|
||||
min-width: 1.2em;
|
||||
}
|
||||
|
||||
.like-tip-modal-target {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.like-tip-modal-error {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
@@ -107,7 +107,7 @@ function PostThreadModal ({ show, txid, onHide, wallet, profiles: externalProfil
|
||||
|
||||
{!loading && !error && thread && (
|
||||
<>
|
||||
<PostThreadNode post={thread} profiles={profiles} isRoot />
|
||||
<PostThreadNode post={thread} profiles={profiles} wallet={wallet} isRoot />
|
||||
<ReplyThreadForm
|
||||
parentTxid={txid}
|
||||
rootPost={thread}
|
||||
@@ -120,6 +120,7 @@ function PostThreadModal ({ show, txid, onHide, wallet, profiles: externalProfil
|
||||
key={reply.txid}
|
||||
post={reply}
|
||||
profiles={profiles}
|
||||
wallet={wallet}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -6,7 +6,7 @@ import React from 'react'
|
||||
|
||||
import PostFeedItem from '../post-feed/post-feed-item'
|
||||
|
||||
function PostThreadNode ({ post, profiles = {}, depth = 0, isRoot = false }) {
|
||||
function PostThreadNode ({ post, profiles = {}, wallet, depth = 0, isRoot = false }) {
|
||||
if (!post) return null
|
||||
|
||||
const profile = profiles[post.addr] || {}
|
||||
@@ -21,6 +21,7 @@ function PostThreadNode ({ post, profiles = {}, depth = 0, isRoot = false }) {
|
||||
post={post}
|
||||
profile={profile}
|
||||
profiles={profiles}
|
||||
wallet={wallet}
|
||||
showRepliedLabel={!isRoot}
|
||||
showReplyCount={false}
|
||||
embedded
|
||||
@@ -30,6 +31,7 @@ function PostThreadNode ({ post, profiles = {}, depth = 0, isRoot = false }) {
|
||||
key={reply.txid}
|
||||
post={reply}
|
||||
profiles={profiles}
|
||||
wallet={wallet}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
Hex encoding helpers used by Memo protocol actions.
|
||||
|
||||
A Bitcoin Cash transaction id is a 32-byte value encoded as a 64-character
|
||||
hex string. Memo actions like replies and likes need to embed that txid in
|
||||
the OP_RETURN payload as raw bytes, so this module provides a small,
|
||||
testable conversion helper.
|
||||
*/
|
||||
|
||||
// Decode a hex string into a Uint8Array of the requested byte length.
|
||||
// The label parameter customizes error messages for the caller's context.
|
||||
function hexToBytes (hex, byteLength = 32, label = 'Value') {
|
||||
if (typeof hex !== 'string' || hex.length !== byteLength * 2) {
|
||||
throw new Error(`${label} must be a ${byteLength * 2}-character hex string.`)
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(byteLength)
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
const byte = parseInt(hex.substr(i, 2), 16)
|
||||
if (Number.isNaN(byte)) {
|
||||
throw new Error(`${label} must be a valid hex string.`)
|
||||
}
|
||||
bytes[i / 2] = byte
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
module.exports = { hexToBytes }
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
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')
|
||||
|
||||
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']
|
||||
}
|
||||
|
||||
// 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.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
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
Memo like/tip behavior: compose, validate, and broadcast a Memo "like"
|
||||
action with an optional tip.
|
||||
|
||||
A Memo like is an OP_RETURN Bitcoin Cash transaction carrying the Memo like
|
||||
protocol prefix (0x6d04) followed by the liked post transaction hash (32
|
||||
bytes). When the user adds a tip, an additional P2PKH output sending the tip
|
||||
amount to the post author's address is included in the same transaction.
|
||||
|
||||
Broadcasting is done through a wallet that exposes the minimal-slp-wallet
|
||||
adapter surface (walletInfo, getUtxos(), sendOpReturn(msg, prefix, bchOutput)).
|
||||
|
||||
The wallet and feed are injected so this module stays testable and free of
|
||||
network/UI concerns; environmentally unsuitable I/O lives behind those small
|
||||
adapter boundaries.
|
||||
|
||||
Constants
|
||||
MEMO_LIKE_PREFIX : hex prefix for the Memo "like" action (0x6d04)
|
||||
DUST_LIMIT_SATS : smallest non-dust BCH output (3000 sats)
|
||||
MAX_TIP_SATS : sanity maximum for a single tip (100000000 sats = 1 BCH)
|
||||
PARENT_TXID_BYTES : liked post txid size in bytes (32)
|
||||
*/
|
||||
|
||||
const MemoAction = require('./memo-action')
|
||||
const { hexToBytes } = require('./hex')
|
||||
|
||||
const MEMO_LIKE_PREFIX = '6d04'
|
||||
const DUST_LIMIT_SATS = 3000
|
||||
const MAX_TIP_SATS = 100000000
|
||||
const PARENT_TXID_BYTES = 32
|
||||
|
||||
class MemoLike extends MemoAction {
|
||||
static config = {
|
||||
prefix: MEMO_LIKE_PREFIX,
|
||||
walletRequiredMsg: 'Memo like requires a wallet.',
|
||||
lengthMessage: 'Post txid must be a 64-character hex string.',
|
||||
emptyMessage: 'Post txid must be a 64-character hex string.',
|
||||
lengthCode: 'like_validation',
|
||||
validationCode: 'like_validation'
|
||||
}
|
||||
|
||||
constructor (deps = {}) {
|
||||
super(deps)
|
||||
this.feed = deps.feed
|
||||
this.dustLimit = deps.dustLimit || DUST_LIMIT_SATS
|
||||
this.maxTip = deps.maxTip || MAX_TIP_SATS
|
||||
}
|
||||
|
||||
// Validate a candidate post txid.
|
||||
// Returns { ok: true } or throws a typed validation error.
|
||||
validate (postTxid) {
|
||||
try {
|
||||
hexToBytes(postTxid, PARENT_TXID_BYTES, 'Post txid')
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
const validationErr = new Error(err.message)
|
||||
validationErr.code = this.validationCode
|
||||
throw validationErr
|
||||
}
|
||||
}
|
||||
|
||||
// Validate an optional tip amount against the dust limit, hard maximum, and
|
||||
// the wallet's spendable balance.
|
||||
validateTip (tipSats, spendableSats) {
|
||||
if (!Number.isInteger(tipSats) || tipSats < 0) {
|
||||
const err = new Error('Tip must be a valid number of satoshis.')
|
||||
err.code = 'like_validation'
|
||||
throw err
|
||||
}
|
||||
|
||||
if (tipSats > 0 && tipSats < this.dustLimit) {
|
||||
const err = new Error(`Tip is below the dust limit of ${this.dustLimit} sats.`)
|
||||
err.code = 'like_dust'
|
||||
throw err
|
||||
}
|
||||
|
||||
if (tipSats > this.maxTip) {
|
||||
const err = new Error(`Tip exceeds the maximum of ${this.maxTip} sats.`)
|
||||
err.code = 'like_maximum'
|
||||
throw err
|
||||
}
|
||||
|
||||
if (tipSats > spendableSats) {
|
||||
const err = new Error('Tip exceeds the spendable balance.')
|
||||
err.code = 'like_balance'
|
||||
throw err
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
// Sum the wallet's spendable UTXOs. Tolerates the common value field names
|
||||
// used by different wallet adapters.
|
||||
getSpendableSats () {
|
||||
if (!this.wallet) return 0
|
||||
const utxos = this.wallet.utxos || []
|
||||
return utxos.reduce((sum, u) => {
|
||||
const value = u.value ?? u.satoshis ?? u.amount ?? 0
|
||||
return sum + value
|
||||
}, 0)
|
||||
}
|
||||
|
||||
// Compose and broadcast a Memo like for the given post txid.
|
||||
// tipSats is optional (defaults to 0). authorAddress is required when
|
||||
// tipSats is greater than 0.
|
||||
// Resolves with the transaction id, or rejects with a typed error.
|
||||
async like (postTxid, tipSats = 0, authorAddress = '') {
|
||||
this.validate(postTxid)
|
||||
|
||||
if (!this.wallet) {
|
||||
throw new Error(this.walletRequiredMsg)
|
||||
}
|
||||
|
||||
await this.wallet.getUtxos()
|
||||
|
||||
const spendable = this.getSpendableSats()
|
||||
if (spendable < this.dustLimit) {
|
||||
const err = new Error('add BCH to your wallet before liking a post.')
|
||||
err.code = 'like_empty_balance'
|
||||
throw err
|
||||
}
|
||||
|
||||
this.validateTip(tipSats, spendable)
|
||||
|
||||
if (tipSats > 0 && (!authorAddress || typeof authorAddress !== 'string')) {
|
||||
const err = new Error('Tip requires an author address.')
|
||||
err.code = 'like_validation'
|
||||
throw err
|
||||
}
|
||||
|
||||
const raw = hexToBytes(postTxid, PARENT_TXID_BYTES, 'Post txid')
|
||||
const bchOutput = tipSats > 0
|
||||
? [{ address: authorAddress, amountSat: tipSats }]
|
||||
: []
|
||||
|
||||
const txid = await this.wallet.sendOpReturn(raw, this.prefix, bchOutput)
|
||||
|
||||
this.reflect(txid, postTxid, tipSats)
|
||||
|
||||
return txid
|
||||
}
|
||||
|
||||
// Record the new like on the injected feed when one is present.
|
||||
reflect (txid, postTxid, tipSats) {
|
||||
if (this.feed && typeof this.feed.addLike === 'function') {
|
||||
this.feed.addLike({
|
||||
txid,
|
||||
postTxid,
|
||||
address: this.wallet.walletInfo.cashAddress,
|
||||
tipSats
|
||||
})
|
||||
}
|
||||
|
||||
if (this.feed && Array.isArray(this.feed.posts)) {
|
||||
const post = this.feed.posts.find((p) => p.txid === postTxid)
|
||||
if (post) {
|
||||
post.likeCount = (post.likeCount || 0) + 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
MemoLike.MEMO_LIKE_PREFIX = MEMO_LIKE_PREFIX
|
||||
MemoLike.DUST_LIMIT_SATS = DUST_LIMIT_SATS
|
||||
MemoLike.MAX_TIP_SATS = MAX_TIP_SATS
|
||||
|
||||
module.exports = MemoLike
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
const MemoAction = require('./memo-action')
|
||||
const { byteLength } = require('./utf8')
|
||||
const { hexToBytes } = require('./hex')
|
||||
|
||||
const MEMO_REPLY_PREFIX = '6d03'
|
||||
const MAX_REPLY_BYTES = 184
|
||||
@@ -82,7 +83,7 @@ class MemoReply extends MemoAction {
|
||||
// Build the raw OP_RETURN message payload for a reply.
|
||||
// The protocol wire format is: <parent txid 32 bytes><reply text UTF-8 bytes>.
|
||||
function buildReplyPayload (parentTxid, message) {
|
||||
const parentBytes = hexToBytes(parentTxid)
|
||||
const parentBytes = hexToBytes(parentTxid, PARENT_TXID_BYTES, 'Parent txid')
|
||||
const textBytes = new TextEncoder().encode(message)
|
||||
const raw = new Uint8Array(parentBytes.length + textBytes.length)
|
||||
raw.set(parentBytes, 0)
|
||||
@@ -90,22 +91,6 @@ function buildReplyPayload (parentTxid, message) {
|
||||
return raw
|
||||
}
|
||||
|
||||
// Decode a 64-character hex transaction id into 32 raw bytes.
|
||||
function hexToBytes (hex) {
|
||||
if (typeof hex !== 'string' || hex.length !== PARENT_TXID_BYTES * 2) {
|
||||
throw new Error('Parent txid must be a 64-character hex string.')
|
||||
}
|
||||
const bytes = new Uint8Array(PARENT_TXID_BYTES)
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
const byte = parseInt(hex.substr(i, 2), 16)
|
||||
if (Number.isNaN(byte)) {
|
||||
throw new Error('Parent txid must be a valid hex string.')
|
||||
}
|
||||
bytes[i / 2] = byte
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
MemoReply.MEMO_REPLY_PREFIX = MEMO_REPLY_PREFIX
|
||||
MemoReply.MAX_REPLY_BYTES = MAX_REPLY_BYTES
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
|
||||
|
||||
// Build the optimistic reply object used by the reply form for immediate
|
||||
// thread rendering before the thread is refreshed from the network. This is a
|
||||
// pure data-shaping function so the reply object shape is unit-testable and
|
||||
|
||||
@@ -6,20 +6,28 @@
|
||||
// sendOpReturn throw.
|
||||
function fakeWallet ({
|
||||
cashAddress = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d',
|
||||
utxos = [{ txid: 'utxo1' }],
|
||||
utxos = [{ txid: 'utxo1', value: 100000 }],
|
||||
txid = 'fake-txid'
|
||||
} = {}) {
|
||||
const broadcasts = []
|
||||
const sends = []
|
||||
const wallet = {
|
||||
walletInfo: { cashAddress },
|
||||
utxos,
|
||||
getUtxos: async () => utxos,
|
||||
sendOpReturn: async function (msg, prefix) {
|
||||
broadcasts.push({ msg, prefix })
|
||||
sendOpReturn: async function (msg, prefix, bchOutput = []) {
|
||||
broadcasts.push({ msg, prefix, bchOutput })
|
||||
if (this.failWith) throw new Error(this.failWith)
|
||||
return txid
|
||||
},
|
||||
send: async function (receivers) {
|
||||
sends.push(receivers)
|
||||
if (this.failWith) throw new Error(this.failWith)
|
||||
return txid
|
||||
}
|
||||
}
|
||||
wallet.broadcasts = broadcasts
|
||||
wallet.sends = sends
|
||||
return wallet
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
/*
|
||||
Unit tests for the Like / Tip page behavior slice
|
||||
(src/services/like-tip-page.js).
|
||||
|
||||
These tests express the page-level behavior described by
|
||||
specs/like-tip-memo.feature:
|
||||
- opening the modal for a post checks the wallet balance.
|
||||
- submitting a like without a tip broadcasts the Memo like action.
|
||||
- submitting a like with a tip broadcasts the action and the tip.
|
||||
- invalid, dust, maximum, and balance errors are surfaced.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const LikeTipPage = require('../../src/services/like-tip-page')
|
||||
const MemoLike = require('../../src/services/memo-like')
|
||||
const { fakeWallet } = require('../helpers/fake-wallet')
|
||||
|
||||
const POST_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||
const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc'
|
||||
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
|
||||
function build () {
|
||||
const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [{ txid: 'u1', value: 100000 }] })
|
||||
const feed = { posts: [], likes: [], addLike: (l) => feed.likes.push(l) }
|
||||
const memoLike = new MemoLike({ wallet, feed })
|
||||
const page = new LikeTipPage({ memoLike })
|
||||
return { wallet, feed, memoLike, page }
|
||||
}
|
||||
|
||||
test('opening the modal sets the target post and author', () => {
|
||||
const { page } = build()
|
||||
|
||||
const result = page.open(POST_TXID, AUTHOR_ADDRESS)
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(page.modalOpen, true)
|
||||
assert.equal(page.postTxid, POST_TXID)
|
||||
assert.equal(page.authorAddress, AUTHOR_ADDRESS)
|
||||
})
|
||||
|
||||
test('opening the modal with insufficient balance surfaces an add-BCH error', () => {
|
||||
const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [] })
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
const page = new LikeTipPage({ memoLike })
|
||||
|
||||
const result = page.open(POST_TXID, AUTHOR_ADDRESS)
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error, 'like_empty_balance')
|
||||
assert.equal(page.submitError, 'like_empty_balance')
|
||||
assert.match(page.broadcastError, /add BCH/i)
|
||||
assert.equal(page.modalOpen, true)
|
||||
})
|
||||
|
||||
test('opening the modal without a memo-like handler surfaces an error', () => {
|
||||
const page = new LikeTipPage({})
|
||||
|
||||
const result = page.open(POST_TXID, AUTHOR_ADDRESS)
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error, 'like_validation')
|
||||
assert.match(page.broadcastError, /memo like/i)
|
||||
})
|
||||
|
||||
test('closing the modal resets input and errors', () => {
|
||||
const { page } = build()
|
||||
page.open(POST_TXID, AUTHOR_ADDRESS)
|
||||
page.setTip('5000')
|
||||
page.submitError = 'like_dust'
|
||||
page.broadcastError = 'some error'
|
||||
|
||||
page.close()
|
||||
|
||||
assert.equal(page.modalOpen, false)
|
||||
assert.equal(page.input, '')
|
||||
assert.equal(page.submitError, null)
|
||||
assert.equal(page.broadcastError, null)
|
||||
})
|
||||
|
||||
test('submitting a pure like broadcasts the Memo like prefix', async () => {
|
||||
const { wallet, page } = build()
|
||||
page.open(POST_TXID, AUTHOR_ADDRESS)
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(page.tipping, false)
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
assert.equal(wallet.broadcasts[0].prefix, '6d04')
|
||||
assert.deepEqual(wallet.broadcasts[0].bchOutput, [])
|
||||
})
|
||||
|
||||
test('submitting a like with a tip broadcasts the prefix and the tip output', async () => {
|
||||
const { wallet, page } = build()
|
||||
page.open(POST_TXID, AUTHOR_ADDRESS)
|
||||
page.setTip('3000')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
assert.equal(wallet.broadcasts[0].prefix, '6d04')
|
||||
assert.deepEqual(wallet.broadcasts[0].bchOutput, [{ address: AUTHOR_ADDRESS, amountSat: 3000 }])
|
||||
})
|
||||
|
||||
test('submitting a like on a post authored by the wallet works without a tip', async () => {
|
||||
const { wallet, page } = build()
|
||||
page.open(POST_TXID, MY_ADDRESS)
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
assert.equal(wallet.broadcasts[0].prefix, '6d04')
|
||||
})
|
||||
|
||||
test('submitting with a non-numeric tip is rejected', async () => {
|
||||
const { wallet, page } = build()
|
||||
page.open(POST_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.submitError, 'like_validation')
|
||||
assert.match(page.broadcastError, /valid number/i)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('submitting with a decimal tip string is rejected', async () => {
|
||||
const { wallet, page } = build()
|
||||
page.open(POST_TXID, AUTHOR_ADDRESS)
|
||||
page.setTip('1.5')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error, 'like_validation')
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('submitting with a dust tip is rejected', async () => {
|
||||
const { wallet, page } = build()
|
||||
page.open(POST_TXID, AUTHOR_ADDRESS)
|
||||
page.setTip('2999')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error, 'like_dust')
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('submitting with a tip above the maximum is rejected', async () => {
|
||||
const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [{ txid: 'u1', value: 150000000 }] })
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
const page = new LikeTipPage({ memoLike })
|
||||
page.open(POST_TXID, AUTHOR_ADDRESS)
|
||||
page.setTip('100000001')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error, 'like_maximum')
|
||||
assert.match(page.broadcastError, /maximum/i)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('submitting with a tip above the spendable balance is rejected', async () => {
|
||||
const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [{ txid: 'u1', value: 30000 }] })
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
const page = new LikeTipPage({ memoLike })
|
||||
page.open(POST_TXID, AUTHOR_ADDRESS)
|
||||
page.setTip('35000')
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.error, 'like_balance')
|
||||
assert.match(page.broadcastError, /spendable/i)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('tipping flag is true while a submit is in flight and false once it settles', async () => {
|
||||
const { wallet, page } = build()
|
||||
page.open(POST_TXID, AUTHOR_ADDRESS)
|
||||
|
||||
let resolveSend
|
||||
wallet.sendOpReturn = async () => new Promise((resolve) => { resolveSend = resolve })
|
||||
|
||||
assert.equal(page.tipping, false)
|
||||
const pending = page.submit()
|
||||
assert.equal(page.tipping, true)
|
||||
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
assert.equal(typeof resolveSend, 'function')
|
||||
resolveSend('in-flight-txid')
|
||||
await pending
|
||||
assert.equal(page.tipping, false)
|
||||
})
|
||||
|
||||
test('submitting without a memo-like handler reports an error', async () => {
|
||||
const page = new LikeTipPage({})
|
||||
page.open(POST_TXID, AUTHOR_ADDRESS)
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(page.submitError, 'broadcast')
|
||||
assert.match(page.broadcastError, /memo like/i)
|
||||
})
|
||||
|
||||
test('a failed broadcast surfaces the real error', async () => {
|
||||
const wallet = fakeWallet({ cashAddress: MY_ADDRESS })
|
||||
wallet.failWith = 'Insufficient balance'
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
const page = new LikeTipPage({ memoLike })
|
||||
page.open(POST_TXID, AUTHOR_ADDRESS)
|
||||
|
||||
const result = await page.submit()
|
||||
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(page.submitError, 'broadcast')
|
||||
assert.match(page.broadcastError, /Insufficient balance/)
|
||||
})
|
||||
@@ -0,0 +1,261 @@
|
||||
/*
|
||||
Unit tests for the Memo like/tip behavior slice (src/services/memo-like.js).
|
||||
|
||||
These tests express the observable behavior described by
|
||||
specs/like-tip-memo.feature:
|
||||
- a valid like broadcasts an OP_RETURN transaction carrying the Memo like
|
||||
prefix (0x6d04) and the post txid bytes; an optional tip is included as a
|
||||
BCH output to the author address.
|
||||
- an invalid/non-integer tip is rejected with a validation error.
|
||||
- a tip below the dust limit is rejected.
|
||||
- a tip above the hard maximum is rejected.
|
||||
- a tip above the spendable balance is rejected.
|
||||
- a wallet without spendable balance cannot like.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
|
||||
const MemoLike = require('../../src/services/memo-like')
|
||||
const { fakeWallet } = require('../helpers/fake-wallet')
|
||||
|
||||
const POST_TXID = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
|
||||
const AUTHOR_ADDRESS = 'bitcoincash:qz7v6ztvzu2f2xd2ww8pnx9vwk0g4ncvfvavktg0jc'
|
||||
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
|
||||
// A fake feed that records likes and exposes posts keyed by txid.
|
||||
function fakeFeed (posts = []) {
|
||||
const likes = []
|
||||
return {
|
||||
posts,
|
||||
likes,
|
||||
addLike: (l) => likes.push(l)
|
||||
}
|
||||
}
|
||||
|
||||
// Decode a like payload back into a 64-character hex txid.
|
||||
function decodeLikeTxid (raw) {
|
||||
return Buffer.from(raw).toString('hex')
|
||||
}
|
||||
|
||||
test('MEMO_LIKE_PREFIX is the Memo like action 0x6d04', () => {
|
||||
assert.equal(MemoLike.MEMO_LIKE_PREFIX, '6d04')
|
||||
})
|
||||
|
||||
test('DUST_LIMIT_SATS is 3000', () => {
|
||||
assert.equal(MemoLike.DUST_LIMIT_SATS, 3000)
|
||||
})
|
||||
|
||||
test('MAX_TIP_SATS is 100000000', () => {
|
||||
assert.equal(MemoLike.MAX_TIP_SATS, 100000000)
|
||||
})
|
||||
|
||||
test('liking a post without a tip broadcasts an OP_RETURN with the Memo like prefix and txid', async () => {
|
||||
const wallet = fakeWallet({ cashAddress: MY_ADDRESS })
|
||||
const feed = fakeFeed([{ txid: POST_TXID, addr: AUTHOR_ADDRESS, likeCount: 0 }])
|
||||
const memoLike = new MemoLike({ wallet, feed })
|
||||
|
||||
const txid = await memoLike.like(POST_TXID, 0, AUTHOR_ADDRESS)
|
||||
|
||||
assert.equal(txid, 'fake-txid')
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
const b = wallet.broadcasts[0]
|
||||
assert.equal(b.prefix, '6d04')
|
||||
assert.ok(b.msg instanceof Uint8Array)
|
||||
assert.equal(decodeLikeTxid(b.msg), POST_TXID)
|
||||
assert.deepEqual(b.bchOutput, [])
|
||||
|
||||
// The feed reflects the like.
|
||||
assert.equal(feed.likes.length, 1)
|
||||
assert.equal(feed.likes[0].postTxid, POST_TXID)
|
||||
assert.equal(feed.likes[0].address, MY_ADDRESS)
|
||||
assert.equal(feed.likes[0].tipSats, 0)
|
||||
assert.equal(feed.posts[0].likeCount, 1)
|
||||
})
|
||||
|
||||
test('liking a post with a tip broadcasts an OP_RETURN and a tip output', async () => {
|
||||
const wallet = fakeWallet({ cashAddress: MY_ADDRESS, utxos: [{ txid: 'u1', value: 100000 }] })
|
||||
const feed = fakeFeed([{ txid: POST_TXID, addr: AUTHOR_ADDRESS, likeCount: 5 }])
|
||||
const memoLike = new MemoLike({ wallet, feed })
|
||||
|
||||
const txid = await memoLike.like(POST_TXID, 3000, AUTHOR_ADDRESS)
|
||||
|
||||
assert.equal(txid, 'fake-txid')
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
const b = wallet.broadcasts[0]
|
||||
assert.equal(b.prefix, '6d04')
|
||||
assert.equal(decodeLikeTxid(b.msg), POST_TXID)
|
||||
assert.deepEqual(b.bchOutput, [{ address: AUTHOR_ADDRESS, amountSat: 3000 }])
|
||||
|
||||
// The feed reflects the like.
|
||||
assert.equal(feed.likes.length, 1)
|
||||
assert.equal(feed.likes[0].tipSats, 3000)
|
||||
assert.equal(feed.posts[0].likeCount, 6)
|
||||
})
|
||||
|
||||
test('liking without a wallet reports a missing-wallet error', async () => {
|
||||
const memoLike = new MemoLike({})
|
||||
|
||||
await assert.rejects(
|
||||
memoLike.like(POST_TXID),
|
||||
(err) => /wallet/i.test(err.message)
|
||||
)
|
||||
})
|
||||
|
||||
test('liking with an invalid post txid reports a clear validation error', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
memoLike.like('not-a-txid'),
|
||||
(err) => err.code === 'like_validation'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('liking with a wrong-length but valid-hex post txid is rejected', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
memoLike.like('a'.repeat(10)),
|
||||
(err) => err.code === 'like_validation'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('a non-integer tip like "1.5" is rejected with a validation error', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
memoLike.like(POST_TXID, 1.5, AUTHOR_ADDRESS),
|
||||
(err) => err.code === 'like_validation'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('a non-numeric tip like "abc" is rejected with a validation error', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
memoLike.like(POST_TXID, NaN, AUTHOR_ADDRESS),
|
||||
(err) => err.code === 'like_validation'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('a negative tip is rejected with a validation error', async () => {
|
||||
const wallet = fakeWallet()
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
memoLike.like(POST_TXID, -1, AUTHOR_ADDRESS),
|
||||
(err) => err.code === 'like_validation'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('a tip below the dust limit is rejected with a dust error', async () => {
|
||||
const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 100000 }] })
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
for (const tip of [1, 2999]) {
|
||||
await assert.rejects(
|
||||
memoLike.like(POST_TXID, tip, AUTHOR_ADDRESS),
|
||||
(err) => err.code === 'like_dust'
|
||||
)
|
||||
}
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('a tip at the dust limit is accepted', async () => {
|
||||
const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 100000 }] })
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
const txid = await memoLike.like(POST_TXID, 3000, AUTHOR_ADDRESS)
|
||||
|
||||
assert.equal(txid, 'fake-txid')
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
})
|
||||
|
||||
test('a tip above the hard maximum is rejected with a maximum error', async () => {
|
||||
const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 150000000 }] })
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
memoLike.like(POST_TXID, 100000001, AUTHOR_ADDRESS),
|
||||
(err) => err.code === 'like_maximum'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('a tip above the spendable balance is rejected with a balance error', async () => {
|
||||
const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 30000 }] })
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
memoLike.like(POST_TXID, 35000, AUTHOR_ADDRESS),
|
||||
(err) => err.code === 'like_balance'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('a wallet with zero spendable balance cannot like', async () => {
|
||||
const wallet = fakeWallet({ utxos: [] })
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
memoLike.like(POST_TXID, 0, AUTHOR_ADDRESS),
|
||||
(err) => err.code === 'like_empty_balance'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('a wallet with balance below the dust limit cannot like', async () => {
|
||||
const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 2999 }] })
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
memoLike.like(POST_TXID, 0, AUTHOR_ADDRESS),
|
||||
(err) => err.code === 'like_empty_balance'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('a pure like can be made on a post authored by the wallet address', async () => {
|
||||
const wallet = fakeWallet({ cashAddress: MY_ADDRESS })
|
||||
const feed = fakeFeed([{ txid: POST_TXID, addr: MY_ADDRESS, likeCount: 0 }])
|
||||
const memoLike = new MemoLike({ wallet, feed })
|
||||
|
||||
const txid = await memoLike.like(POST_TXID, 0, MY_ADDRESS)
|
||||
|
||||
assert.equal(txid, 'fake-txid')
|
||||
assert.equal(wallet.broadcasts.length, 1)
|
||||
assert.equal(wallet.broadcasts[0].prefix, '6d04')
|
||||
assert.equal(feed.posts[0].likeCount, 1)
|
||||
})
|
||||
|
||||
test('a tip requires an author address', async () => {
|
||||
const wallet = fakeWallet({ utxos: [{ txid: 'u1', value: 100000 }] })
|
||||
const memoLike = new MemoLike({ wallet })
|
||||
|
||||
await assert.rejects(
|
||||
memoLike.like(POST_TXID, 3000),
|
||||
(err) => err.code === 'like_validation'
|
||||
)
|
||||
assert.equal(wallet.broadcasts.length, 0)
|
||||
})
|
||||
|
||||
test('getSpendableSats tolerates common utxo value field names', () => {
|
||||
const walletValue = fakeWallet({ utxos: [{ txid: 'u1', value: 1000 }] })
|
||||
const walletSatoshis = fakeWallet({ utxos: [{ txid: 'u2', satoshis: 2000 }] })
|
||||
const walletAmount = fakeWallet({ utxos: [{ txid: 'u3', amount: 3000 }] })
|
||||
|
||||
assert.equal(new MemoLike({ wallet: walletValue }).getSpendableSats(), 1000)
|
||||
assert.equal(new MemoLike({ wallet: walletSatoshis }).getSpendableSats(), 2000)
|
||||
assert.equal(new MemoLike({ wallet: walletAmount }).getSpendableSats(), 3000)
|
||||
})
|
||||
Reference in New Issue
Block a user