diff --git a/src/components/app-body/posts/index.js b/src/components/app-body/posts/index.js index a179e43..b974758 100644 --- a/src/components/app-body/posts/index.js +++ b/src/components/app-body/posts/index.js @@ -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 () {
{post.text}
- + openThread(post.txid)} + /> {post.blockHeight} {formatSeen(post.seen)} @@ -156,6 +172,12 @@ function RecentPosts () { )} + + ) } diff --git a/src/components/app-body/profile/index.js b/src/components/app-body/profile/index.js index 167a9b9..fb1b623 100644 --- a/src/components/app-body/profile/index.js +++ b/src/components/app-body/profile/index.js @@ -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 () { Block {post.blockHeight} {post.text} - + openThread(post.txid)} + /> ))} )} + + ) } diff --git a/src/components/post-reply-count/index.js b/src/components/post-reply-count/index.js index 0294fd7..cf17f07 100644 --- a/src/components/post-reply-count/index.js +++ b/src/components/post-reply-count/index.js @@ -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 ( -
+
{count}
diff --git a/src/components/post-reply-count/post-reply-count.css b/src/components/post-reply-count/post-reply-count.css index b682720..c9c9f3b 100644 --- a/src/components/post-reply-count/post-reply-count.css +++ b/src/components/post-reply-count/post-reply-count.css @@ -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; +} diff --git a/src/components/post-thread-modal/index.js b/src/components/post-thread-modal/index.js new file mode 100644 index 0000000..b89fbe1 --- /dev/null +++ b/src/components/post-thread-modal/index.js @@ -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 ( + + + Post thread + + + {loading && ( +
+ + Loading replies... + +
+ )} + + {error && !loading && ( +

{error}

+ )} + + {!loading && !error && thread && ( + + )} +
+
+ ) +} + +export default PostThreadModal diff --git a/src/components/post-thread-modal/post-thread-modal.css b/src/components/post-thread-modal/post-thread-modal.css new file mode 100644 index 0000000..75139fc --- /dev/null +++ b/src/components/post-thread-modal/post-thread-modal.css @@ -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; +} diff --git a/src/components/post-thread-modal/post-thread-node.js b/src/components/post-thread-modal/post-thread-node.js new file mode 100644 index 0000000..1d3b440 --- /dev/null +++ b/src/components/post-thread-modal/post-thread-node.js @@ -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 ( +
0 ? `${Math.min(depth, 8) * 1.25}rem` : undefined }} + > +
+
+ + {truncateAddr(post.addr, 24)} + + {!isRoot && replied} + {formatSeen(post.seen)} +
+
{post.text}
+ {(post.replies || []).map((reply) => ( + + ))} +
+
+ ) +} + +export default PostThreadNode diff --git a/src/services/memo-db.js b/src/services/memo-db.js index 1704aec..251ed0f 100644 --- a/src/services/memo-db.js +++ b/src/services/memo-db.js @@ -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