Showing replies in modal

This commit is contained in:
Chris Troutner
2026-06-06 07:33:13 -07:00
parent d5d58f816e
commit 0a3212e6ca
8 changed files with 269 additions and 4 deletions
+23 -1
View File
@@ -11,6 +11,7 @@ import { Container, Row, Col, Spinner, Table, Button } from 'react-bootstrap'
import MemoDb from '../../../services/memo-db'
import AppUtil from '../../../util'
import PostReplyCount from '../../post-reply-count'
import PostThreadModal from '../../post-thread-modal'
import '../../../App.css'
const appUtil = new AppUtil()
@@ -34,6 +35,18 @@ function RecentPosts () {
const [posts, setPosts] = useState([])
const [pagination, setPagination] = useState(null)
const [offset, setOffset] = useState(0)
const [threadTxid, setThreadTxid] = useState(null)
const [showThreadModal, setShowThreadModal] = useState(false)
const openThread = (txid) => {
setThreadTxid(txid)
setShowThreadModal(true)
}
const closeThread = () => {
setShowThreadModal(false)
setThreadTxid(null)
}
useEffect(() => {
const loadPosts = async () => {
@@ -117,7 +130,10 @@ function RecentPosts () {
</td>
<td>
<div>{post.text}</div>
<PostReplyCount count={post.replyCount ?? 0} />
<PostReplyCount
count={post.replyCount ?? 0}
onClick={() => openThread(post.txid)}
/>
</td>
<td>{post.blockHeight}</td>
<td>{formatSeen(post.seen)}</td>
@@ -156,6 +172,12 @@ function RecentPosts () {
)}
</Col>
</Row>
<PostThreadModal
show={showThreadModal}
txid={threadTxid}
onHide={closeThread}
/>
</Container>
)
}
+23 -1
View File
@@ -9,6 +9,7 @@ import Jdenticon from '@chris.troutner/react-jdenticon'
import MemoDb from '../../../services/memo-db'
import PostReplyCount from '../../post-reply-count'
import PostThreadModal from '../../post-thread-modal'
import '../../../App.css'
import './profile.css'
@@ -53,6 +54,18 @@ function Profile () {
const [profilePicUrl, setProfilePicUrl] = useState(null)
const [posts, setPosts] = useState([])
const [pagination, setPagination] = useState(null)
const [threadTxid, setThreadTxid] = useState(null)
const [showThreadModal, setShowThreadModal] = useState(false)
const openThread = (txid) => {
setThreadTxid(txid)
setShowThreadModal(true)
}
const closeThread = () => {
setShowThreadModal(false)
setThreadTxid(null)
}
useEffect(() => {
const loadProfile = async () => {
@@ -138,13 +151,22 @@ function Profile () {
<span className='profile-post-block ms-2'>Block {post.blockHeight}</span>
</div>
<Card.Text className='profile-post-text'>{post.text}</Card.Text>
<PostReplyCount count={post.replyCount ?? 0} />
<PostReplyCount
count={post.replyCount ?? 0}
onClick={() => openThread(post.txid)}
/>
</Card.Body>
</Card>
))}
</Col>
</Row>
)}
<PostThreadModal
show={showThreadModal}
txid={threadTxid}
onHide={closeThread}
/>
</Container>
)
}
+19 -2
View File
@@ -8,11 +8,28 @@ import { faComment } from '@fortawesome/free-solid-svg-icons'
import './post-reply-count.css'
function PostReplyCount ({ count = 0 }) {
function PostReplyCount ({ count = 0, onClick }) {
const label = count === 1 ? '1 reply' : `${count} replies`
const clickable = count > 0 && typeof onClick === 'function'
const title = clickable ? `${label} — click to view` : label
const handleKeyDown = (event) => {
if (clickable && (event.key === 'Enter' || event.key === ' ')) {
event.preventDefault()
onClick()
}
}
return (
<div className='post-reply-count' title={label} aria-label={label}>
<div
className={`post-reply-count${clickable ? ' post-reply-count-clickable' : ''}`}
title={title}
aria-label={title}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={clickable ? onClick : undefined}
onKeyDown={clickable ? handleKeyDown : undefined}
>
<FontAwesomeIcon icon={faComment} className='post-reply-count-icon' />
<span className='post-reply-count-number'>{count}</span>
</div>
@@ -10,3 +10,11 @@
.post-reply-count-icon {
font-size: 0.9rem;
}
.post-reply-count-clickable {
cursor: pointer;
}
.post-reply-count-clickable:hover {
color: #28a745;
}
+86
View File
@@ -0,0 +1,86 @@
/*
Modal displaying a post and its nested reply thread.
*/
import React, { useState, useEffect } from 'react'
import { Modal, Spinner } from 'react-bootstrap'
import MemoDb from '../../services/memo-db'
import PostThreadNode from './post-thread-node'
import './post-thread-modal.css'
function PostThreadModal ({ show, txid, onHide }) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [thread, setThread] = useState(null)
useEffect(() => {
if (!show || !txid) {
return undefined
}
let cancelled = false
const loadThread = async () => {
setLoading(true)
setError(null)
setThread(null)
try {
const memoDb = new MemoDb()
const data = await memoDb.getPostThread(txid)
if (!cancelled) {
setThread(data.post || null)
}
} catch (err) {
if (!cancelled) {
const message = err.response?.data?.message || err.message || 'Failed to load replies'
setError(message)
}
}
if (!cancelled) {
setLoading(false)
}
}
loadThread()
return () => {
cancelled = true
}
}, [show, txid])
const handleHide = () => {
setThread(null)
setError(null)
onHide()
}
return (
<Modal show={show} onHide={handleHide} size='lg' scrollable centered>
<Modal.Header closeButton>
<Modal.Title>Post thread</Modal.Title>
</Modal.Header>
<Modal.Body className='post-thread-modal-body'>
{loading && (
<div className='text-center my-4'>
<Spinner animation='border' role='status' variant='primary'>
<span className='visually-hidden'>Loading replies...</span>
</Spinner>
</div>
)}
{error && !loading && (
<p className='text-danger mb-0'>{error}</p>
)}
{!loading && !error && thread && (
<PostThreadNode post={thread} isRoot />
)}
</Modal.Body>
</Modal>
)
}
export default PostThreadModal
@@ -0,0 +1,48 @@
.post-thread-modal-body {
max-height: 70vh;
}
.post-thread-node {
margin-top: 0.75rem;
}
.post-thread-node-root {
margin-top: 0;
}
.post-thread-node-inner {
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 0.75rem 1rem;
background: #fff;
}
.post-thread-node-root > .post-thread-node-inner {
border-color: #ced4da;
}
.post-thread-node-header {
font-size: 0.85rem;
margin-bottom: 0.5rem;
}
.post-thread-node-author {
font-family: monospace;
font-size: 0.8rem;
text-decoration: none;
}
.post-thread-node-author:hover {
text-decoration: underline;
}
.post-thread-node-replied {
font-style: italic;
}
.post-thread-node-text {
white-space: pre-wrap;
word-break: break-word;
font-size: 0.95rem;
line-height: 1.5;
}
@@ -0,0 +1,49 @@
/*
Single post node in a reply thread (recursive).
*/
import React from 'react'
import { Link } from 'react-router-dom'
function formatSeen (seen) {
if (!seen) return ''
const ms = seen > 1e12 ? seen : seen * 1000
return new Date(ms).toLocaleString()
}
function truncateAddr (addr, maxLen = 20) {
if (!addr || addr.length <= maxLen) return addr
const half = Math.floor((maxLen - 3) / 2)
return `${addr.slice(0, half)}...${addr.slice(-half)}`
}
function PostThreadNode ({ post, depth = 0, isRoot = false }) {
if (!post) return null
return (
<div
className={`post-thread-node${isRoot ? ' post-thread-node-root' : ''}`}
style={{ marginLeft: depth > 0 ? `${Math.min(depth, 8) * 1.25}rem` : undefined }}
>
<div className='post-thread-node-inner'>
<div className='post-thread-node-header text-muted'>
<Link
to={`/profile/${encodeURIComponent(post.addr)}`}
className='post-thread-node-author'
title={post.addr}
>
{truncateAddr(post.addr, 24)}
</Link>
{!isRoot && <span className='post-thread-node-replied ms-1'>replied</span>}
<span className='post-thread-node-seen ms-2'>{formatSeen(post.seen)}</span>
</div>
<div className='post-thread-node-text'>{post.text}</div>
{(post.replies || []).map((reply) => (
<PostThreadNode key={reply.txid} post={reply} depth={depth + 1} />
))}
</div>
</div>
)
}
export default PostThreadNode
+13
View File
@@ -79,6 +79,19 @@ class MemoDb {
throw err
}
}
async getPostThread (txid) {
try {
const result = await this.axios.get(
`${config.backend}/posts/${encodeURIComponent(txid)}/thread`
)
return result.data
} catch (err) {
console.error('Error in getPostThread()')
throw err
}
}
}
export default MemoDb