+
{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