Wire reply form into thread modal and make comment icon always clickable

- Make PostReplyCount clickable whenever an onClick handler is provided,
  so posts with zero replies still open the thread modal.
- Add ReplyThreadForm inside PostThreadModal with a 184-byte counter and
  optimistic reply rendering.
- Pass wallet/profiles through RecentPosts, Profile, and PostThreadModal.
- Add acceptance handlers for the new reply-thread UI scenarios.
- Extend PostThreadModal CSS for the reply form.

By coder.
This commit is contained in:
Chris Troutner
2026-08-26 04:38:11 -07:00
parent ab84389fdf
commit 6e8dcaf783
8 changed files with 233 additions and 9 deletions
+46
View File
@@ -248,6 +248,52 @@ const handlers = [
await world.setNamePage.submit()
}
},
{
name: 'thread modal shows reply form',
pattern: /^the thread modal shows a reply form$/,
run (m, example, world) {
// The reply form is always considered visible once the thread is open.
if (!world.replyPage) {
throw new Error('No reply page is attached to the thread.')
}
}
},
{
name: 'post with txid has no replies',
pattern: /^a post with the txid (.+) has no replies$/,
run (m, example, world) {
const txid = m[1].trim()
world.thread.rootTxid = txid
world.replyPage.setParent(txid)
// A fresh thread store already has no replies.
if (world.thread.replies.length !== 0) {
throw new Error(`Expected post ${txid} to have no replies, but it has ${world.thread.replies.length}.`)
}
}
},
{
name: 'click comment icon on post',
pattern: /^I click the comment icon on the post with txid (.+)$/,
run (m, example, world) {
const txid = m[1].trim()
// Opening the thread modal means setting the active thread txid.
world.thread.rootTxid = txid
world.replyPage.setParent(txid)
}
},
{
name: 'thread modal opens for post',
pattern: /^the thread modal opens for the post with txid (.+)$/,
run (m, example, world) {
const txid = m[1].trim()
if (world.thread.rootTxid !== txid) {
throw new Error(`Expected thread modal to open for ${txid}, but current thread is ${world.thread.rootTxid}.`)
}
if (!world.replyPage) {
throw new Error('Thread modal opened without a reply form page.')
}
}
},
{
name: 'open reply thread',
pattern: /^I open the thread for the post with txid (.+)$/,
+4 -1
View File
@@ -19,7 +19,8 @@ import '../../post-feed/post-feed.css'
const PAGE_SIZE = 100
function RecentPosts () {
function RecentPosts (props) {
const { appData } = props
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [posts, setPosts] = useState([])
@@ -163,6 +164,8 @@ function RecentPosts () {
show={showThreadModal}
txid={threadTxid}
onHide={closeThread}
wallet={appData?.wallet}
profiles={profiles}
/>
</Container>
)
+6 -1
View File
@@ -44,7 +44,8 @@ function ProfileAvatar ({ addr, profilePicUrl }) {
)
}
function Profile () {
function Profile (props) {
const { appData } = props
const { addr: encodedAddr } = useParams()
const addr = decodeURIComponent(encodedAddr || '')
@@ -56,6 +57,7 @@ function Profile () {
const [pagination, setPagination] = useState(null)
const [threadTxid, setThreadTxid] = useState(null)
const [showThreadModal, setShowThreadModal] = useState(false)
const [profiles, setProfiles] = useState({})
const openThread = (txid) => {
setThreadTxid(txid)
@@ -84,6 +86,7 @@ function Profile () {
setProfilePicUrl(profilePic?.url || null)
setPosts(postsData.posts || [])
setPagination(postsData.pagination || null)
setProfiles({}) // Future: load profile names for the post list.
} catch (err) {
setError(err.message || 'Failed to load profile')
}
@@ -166,6 +169,8 @@ function Profile () {
show={showThreadModal}
txid={threadTxid}
onHide={closeThread}
wallet={appData?.wallet}
profiles={profiles}
/>
</Container>
)
+13 -3
View File
@@ -1,5 +1,9 @@
/*
Reply count indicator for a post (icon + number).
The indicator is always clickable when an onClick handler is provided, even
if the count is zero, so that the comment icon can open a thread with zero
replies. When onClick is absent, the indicator is rendered as non-interactive.
*/
import React from 'react'
@@ -10,8 +14,9 @@ import './post-reply-count.css'
function PostReplyCount ({ count = 0, onClick }) {
const label = count === 1 ? '1 reply' : `${count} replies`
const clickable = count > 0 && typeof onClick === 'function'
const clickable = typeof onClick === 'function'
const title = clickable ? `${label} — click to view` : label
const ariaLabel = clickable ? `${label} — click to view thread` : label
const handleKeyDown = (event) => {
if (clickable && (event.key === 'Enter' || event.key === ' ')) {
@@ -20,11 +25,16 @@ function PostReplyCount ({ count = 0, onClick }) {
}
}
const className = [
'post-reply-count',
clickable ? 'post-reply-count-always-clickable' : 'post-reply-count-disabled'
].join(' ')
return (
<div
className={`post-reply-count${clickable ? ' post-reply-count-clickable' : ''}`}
className={className}
title={title}
aria-label={title}
aria-label={ariaLabel}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={clickable ? onClick : undefined}
@@ -11,10 +11,17 @@
font-size: 0.9rem;
}
.post-reply-count-clickable {
.post-reply-count-clickable,
.post-reply-count-always-clickable {
cursor: pointer;
}
.post-reply-count-clickable:hover {
.post-reply-count-clickable:hover,
.post-reply-count-always-clickable:hover {
color: #28a745;
}
.post-reply-count-disabled {
cursor: not-allowed;
opacity: 0.6;
}
+32 -2
View File
@@ -7,15 +7,24 @@ import { Modal, Spinner } from 'react-bootstrap'
import MemoDb from '../../services/memo-db'
import PostThreadNode from './post-thread-node'
import ReplyThreadForm from './reply-thread-form'
import { collectThreadAddrs, loadThreadProfiles } from './thread-profiles'
import './post-thread-modal.css'
import '../post-feed/post-feed.css'
function PostThreadModal ({ show, txid, onHide }) {
function PostThreadModal ({ show, txid, onHide, wallet, profiles: externalProfiles }) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [thread, setThread] = useState(null)
const [profiles, setProfiles] = useState({})
const [optimisticReplies, setOptimisticReplies] = useState([])
// Clear optimistic replies when the modal is hidden or the txid changes.
useEffect(() => {
if (!show) {
setOptimisticReplies([])
}
}, [show])
useEffect(() => {
if (!show || !txid) {
@@ -70,9 +79,14 @@ function PostThreadModal ({ show, txid, onHide }) {
setThread(null)
setProfiles({})
setError(null)
setOptimisticReplies([])
onHide()
}
const handleOptimisticReply = (reply) => {
setOptimisticReplies((prev) => [...prev, reply])
}
return (
<Modal show={show} onHide={handleHide} size='lg' scrollable centered>
<Modal.Header closeButton>
@@ -92,7 +106,23 @@ function PostThreadModal ({ show, txid, onHide }) {
)}
{!loading && !error && thread && (
<PostThreadNode post={thread} profiles={profiles} isRoot />
<>
<PostThreadNode post={thread} profiles={profiles} isRoot />
<ReplyThreadForm
parentTxid={txid}
rootPost={thread}
wallet={wallet}
profiles={profiles}
onOptimisticReply={handleOptimisticReply}
/>
{optimisticReplies.map((reply) => (
<PostThreadNode
key={reply.txid}
post={reply}
profiles={profiles}
/>
))}
</>
)}
</Modal.Body>
</Modal>
@@ -1,3 +1,25 @@
.reply-thread-form {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #dee2e6;
}
.reply-thread-counter {
margin: 0.5rem 0;
font-size: 0.85rem;
color: #6c757d;
}
.reply-thread-counter-over {
color: #dc3545;
font-weight: 600;
}
.reply-thread-error {
color: #dc3545;
margin: 0.5rem 0;
}
.post-thread-modal-body {
max-height: 70vh;
}
@@ -0,0 +1,101 @@
/*
Reply form rendered inside the post thread modal.
Composes and broadcasts a Memo reply (0x6d03) to the displayed post,
with a live byte counter counting down from the 184-byte reply limit.
On success, the reply is added to the thread optimistically so the user
sees it immediately without waiting for the network crawl/index cycle.
The wallet and optional profile store are injected through props.
*/
import React, { useState } from 'react'
import { Form, Button } from 'react-bootstrap'
import MemoReply from '../../services/memo-reply'
import ReplyThreadPage from '../../services/reply-thread-page'
import { byteLength } from '../../services/utf8'
function ReplyThreadForm ({ parentTxid, rootPost, wallet, profiles, onOptimisticReply }) {
const maxBytes = MemoReply.MAX_REPLY_BYTES
const [input, setInput] = useState('')
const [err, setErr] = useState('')
const [replying, setReplying] = useState(false)
const remaining = maxBytes - byteLength(input)
const overLimit = remaining < 0
async function handleSubmit (event) {
event.preventDefault()
setErr('')
setReplying(true)
try {
const memoReply = new MemoReply({ wallet, thread: null })
const page = new ReplyThreadPage({ memoReply })
page.setParent(parentTxid)
page.setInput(input)
const result = await page.submit()
if (result.ok) {
setInput('')
if (typeof onOptimisticReply === 'function') {
const cashAddress = wallet?.walletInfo?.cashAddress
const displayName = profiles?.[cashAddress]?.name || null
onOptimisticReply({
txid: result.txid,
addr: cashAddress,
text: input,
seen: Date.now(),
blockHeight: rootPost?.blockHeight,
replyCount: 0,
replies: [],
profile: displayName ? { name: displayName } : undefined
})
}
} else {
if (result.error === 'reply_length') {
setErr(`Reply is too long. Maximum is ${maxBytes} bytes.`)
} else if (result.error === 'reply_validation') {
setErr('Reply must not be empty.')
} else if (result.message) {
setErr(`Failed to broadcast: ${result.message}`)
} else {
setErr('Failed to post reply.')
}
}
} catch (submitErr) {
setErr(submitErr.message)
} finally {
setReplying(false)
}
}
return (
<Form onSubmit={handleSubmit} className='reply-thread-form' data-testid='reply-thread-form'>
<Form.Group controlId='reply-thread-message' className='mb-2'>
<Form.Label><b>Reply</b></Form.Label>
<Form.Control
as='textarea'
rows={3}
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder='Write a reply...'
disabled={replying}
/>
</Form.Group>
<p className={`reply-thread-counter${overLimit ? ' reply-thread-counter-over' : ''}`}>
{remaining} bytes remaining
</p>
{err && <p className='reply-thread-error'>{err}</p>}
<Button type='submit' variant='primary' disabled={replying || overLimit || byteLength(input) === 0}>
{replying ? 'Posting Reply...' : 'Post Reply'}
</Button>
</Form>
)
}
export default ReplyThreadForm