mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-client.git
synced 2026-09-21 16:52:02 -07:00
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:
@@ -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 (.+)$/,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user