mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-client.git
synced 2026-09-21 16:52:02 -07:00
Showing replies in modal
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user