From 1b935e0719cd9ef9875c1bfbc8a17f00f04e844b Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sat, 6 Jun 2026 07:01:09 -0700 Subject: [PATCH 1/5] Adding icon to represent replies to a post --- src/components/app-body/posts/index.js | 6 ++++- src/components/app-body/profile/index.js | 2 ++ src/components/post-reply-count/index.js | 22 +++++++++++++++++++ .../post-reply-count/post-reply-count.css | 12 ++++++++++ 4 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 src/components/post-reply-count/index.js create mode 100644 src/components/post-reply-count/post-reply-count.css diff --git a/src/components/app-body/posts/index.js b/src/components/app-body/posts/index.js index f945223..41aec79 100644 --- a/src/components/app-body/posts/index.js +++ b/src/components/app-body/posts/index.js @@ -10,6 +10,7 @@ import { Container, Row, Col, Spinner, Table } from 'react-bootstrap' // Local libraries import MemoDb from '../../../services/memo-db' import AppUtil from '../../../util' +import PostReplyCount from '../../post-reply-count' import '../../../App.css' const appUtil = new AppUtil() @@ -92,7 +93,10 @@ function RecentPosts () { {truncate(post.addr, 24)} - {post.text} + +
{post.text}
+ + {post.blockHeight} {formatSeen(post.seen)} diff --git a/src/components/app-body/profile/index.js b/src/components/app-body/profile/index.js index 54dc62c..167a9b9 100644 --- a/src/components/app-body/profile/index.js +++ b/src/components/app-body/profile/index.js @@ -8,6 +8,7 @@ import { Container, Row, Col, Spinner, Card } from 'react-bootstrap' import Jdenticon from '@chris.troutner/react-jdenticon' import MemoDb from '../../../services/memo-db' +import PostReplyCount from '../../post-reply-count' import '../../../App.css' import './profile.css' @@ -137,6 +138,7 @@ function Profile () { Block {post.blockHeight} {post.text} + ))} diff --git a/src/components/post-reply-count/index.js b/src/components/post-reply-count/index.js new file mode 100644 index 0000000..0294fd7 --- /dev/null +++ b/src/components/post-reply-count/index.js @@ -0,0 +1,22 @@ +/* + Reply count indicator for a post (icon + number). +*/ + +import React from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faComment } from '@fortawesome/free-solid-svg-icons' + +import './post-reply-count.css' + +function PostReplyCount ({ count = 0 }) { + const label = count === 1 ? '1 reply' : `${count} replies` + + return ( +
+ + {count} +
+ ) +} + +export default PostReplyCount diff --git a/src/components/post-reply-count/post-reply-count.css b/src/components/post-reply-count/post-reply-count.css new file mode 100644 index 0000000..b682720 --- /dev/null +++ b/src/components/post-reply-count/post-reply-count.css @@ -0,0 +1,12 @@ +.post-reply-count { + display: inline-flex; + align-items: center; + gap: 0.35rem; + margin-top: 0.75rem; + color: #6c757d; + font-size: 0.85rem; +} + +.post-reply-count-icon { + font-size: 0.9rem; +} From d5d58f816e861b1fe26c15fbc75390012c708db6 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sat, 6 Jun 2026 07:16:56 -0700 Subject: [PATCH 2/5] Adding pagination controls to feed page --- src/components/app-body/posts/index.js | 51 +++++++++++++++++++++++--- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/src/components/app-body/posts/index.js b/src/components/app-body/posts/index.js index 41aec79..a179e43 100644 --- a/src/components/app-body/posts/index.js +++ b/src/components/app-body/posts/index.js @@ -5,7 +5,7 @@ // Global npm libraries import React, { useState, useEffect } from 'react' import { Link } from 'react-router-dom' -import { Container, Row, Col, Spinner, Table } from 'react-bootstrap' +import { Container, Row, Col, Spinner, Table, Button } from 'react-bootstrap' // Local libraries import MemoDb from '../../../services/memo-db' @@ -14,6 +14,7 @@ import PostReplyCount from '../../post-reply-count' import '../../../App.css' const appUtil = new AppUtil() +const PAGE_SIZE = 100 function truncate (str, maxLen = 16) { if (!str || str.length <= maxLen) return str @@ -32,33 +33,54 @@ function RecentPosts () { const [error, setError] = useState(null) const [posts, setPosts] = useState([]) const [pagination, setPagination] = useState(null) + const [offset, setOffset] = useState(0) useEffect(() => { const loadPosts = async () => { + setLoading(true) + setError(null) + try { const memoDb = new MemoDb() - const data = await memoDb.getRecentPosts({ limit: 100, offset: 0 }) + const data = await memoDb.getRecentPosts({ limit: PAGE_SIZE, offset }) setPosts(data.posts || []) setPagination(data.pagination || null) } catch (err) { setError(err.message || 'Failed to load recent posts') + setPosts([]) + setPagination(null) } + setLoading(false) } loadPosts() - }, []) + }, [offset]) + + const canGoBack = offset > 0 + const canGoNext = pagination?.hasMore ?? false + + const handlePrevious = () => { + setOffset((prev) => Math.max(0, prev - PAGE_SIZE)) + } + + const handleNext = () => { + setOffset((prev) => prev + PAGE_SIZE) + } return (

Recent Posts

- {pagination && ( + {pagination && posts.length > 0 && (

- Showing {posts.length} of {pagination.total} posts + Showing {pagination.offset + 1}–{pagination.offset + posts.length} of {pagination.total} posts

)} + {pagination && posts.length === 0 && ( +

No posts on this page.

+ )} {error &&

{error}

} @@ -113,6 +135,25 @@ function RecentPosts () { )} + + {!loading && !error && (pagination || offset > 0) && ( +
+ + +
+ )}
From 0a3212e6ca61653d544a4c87f30cc98c46c751cf Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sat, 6 Jun 2026 07:33:13 -0700 Subject: [PATCH 3/5] Showing replies in modal --- src/components/app-body/posts/index.js | 24 +++++- src/components/app-body/profile/index.js | 24 +++++- src/components/post-reply-count/index.js | 21 ++++- .../post-reply-count/post-reply-count.css | 8 ++ src/components/post-thread-modal/index.js | 86 +++++++++++++++++++ .../post-thread-modal/post-thread-modal.css | 48 +++++++++++ .../post-thread-modal/post-thread-node.js | 49 +++++++++++ src/services/memo-db.js | 13 +++ 8 files changed, 269 insertions(+), 4 deletions(-) create mode 100644 src/components/post-thread-modal/index.js create mode 100644 src/components/post-thread-modal/post-thread-modal.css create mode 100644 src/components/post-thread-modal/post-thread-node.js 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 From 2e8c3b7ea8a55051639740b2dc45ab8816086278 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sat, 6 Jun 2026 07:40:33 -0700 Subject: [PATCH 4/5] Showing names and avatars in reply modal --- src/components/post-thread-modal/index.js | 21 +++++- .../post-thread-modal/post-thread-avatar.js | 35 +++++++++ .../post-thread-modal/post-thread-modal.css | 41 ++++++++++- .../post-thread-modal/post-thread-node.js | 71 +++++++++++++++---- .../post-thread-modal/thread-profiles.js | 36 ++++++++++ src/services/memo-db.js | 15 ++++ 6 files changed, 199 insertions(+), 20 deletions(-) create mode 100644 src/components/post-thread-modal/post-thread-avatar.js create mode 100644 src/components/post-thread-modal/thread-profiles.js diff --git a/src/components/post-thread-modal/index.js b/src/components/post-thread-modal/index.js index b89fbe1..4e97961 100644 --- a/src/components/post-thread-modal/index.js +++ b/src/components/post-thread-modal/index.js @@ -7,12 +7,14 @@ import { Modal, Spinner } from 'react-bootstrap' import MemoDb from '../../services/memo-db' import PostThreadNode from './post-thread-node' +import { collectThreadAddrs, loadThreadProfiles } from './thread-profiles' 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) + const [profiles, setProfiles] = useState({}) useEffect(() => { if (!show || !txid) { @@ -25,12 +27,24 @@ function PostThreadModal ({ show, txid, onHide }) { setLoading(true) setError(null) setThread(null) + setProfiles({}) try { const memoDb = new MemoDb() const data = await memoDb.getPostThread(txid) - if (!cancelled) { - setThread(data.post || null) + const post = data.post || null + + if (cancelled) return + + if (post) { + const addrs = collectThreadAddrs(post) + const profileMap = await loadThreadProfiles(addrs, memoDb) + if (!cancelled) { + setThread(post) + setProfiles(profileMap) + } + } else if (!cancelled) { + setThread(null) } } catch (err) { if (!cancelled) { @@ -53,6 +67,7 @@ function PostThreadModal ({ show, txid, onHide }) { const handleHide = () => { setThread(null) + setProfiles({}) setError(null) onHide() } @@ -76,7 +91,7 @@ function PostThreadModal ({ show, txid, onHide }) { )} {!loading && !error && thread && ( - + )} diff --git a/src/components/post-thread-modal/post-thread-avatar.js b/src/components/post-thread-modal/post-thread-avatar.js new file mode 100644 index 0000000..871c331 --- /dev/null +++ b/src/components/post-thread-modal/post-thread-avatar.js @@ -0,0 +1,35 @@ +/* + Small avatar for post thread nodes (profile pic or jdenticon fallback). +*/ + +import React, { useState, useEffect } from 'react' +import Jdenticon from '@chris.troutner/react-jdenticon' + +function PostThreadAvatar ({ addr, profilePicUrl, size = 36 }) { + const [picError, setPicError] = useState(false) + + useEffect(() => { + setPicError(false) + }, [profilePicUrl, addr]) + + if (profilePicUrl && !picError) { + return ( + setPicError(true)} + /> + ) + } + + return ( +
+ +
+ ) +} + +export default PostThreadAvatar diff --git a/src/components/post-thread-modal/post-thread-modal.css b/src/components/post-thread-modal/post-thread-modal.css index 75139fc..af22921 100644 --- a/src/components/post-thread-modal/post-thread-modal.css +++ b/src/components/post-thread-modal/post-thread-modal.css @@ -22,24 +22,59 @@ } .post-thread-node-header { - font-size: 0.85rem; + display: flex; + align-items: center; + gap: 0.6rem; margin-bottom: 0.5rem; } +.post-thread-avatar { + flex-shrink: 0; + border-radius: 4px; + object-fit: cover; + display: block; +} + +.post-thread-avatar-jdenticon { + overflow: hidden; + border-radius: 4px; +} + +.post-thread-node-meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.35rem; + font-size: 0.85rem; + color: #6c757d; + min-width: 0; +} + .post-thread-node-author { - font-family: monospace; - font-size: 0.8rem; + font-weight: 600; + color: #212529; text-decoration: none; } .post-thread-node-author:hover { text-decoration: underline; + color: #28a745; +} + +.post-thread-node-author-address { + font-family: monospace; + font-size: 0.8rem; + font-weight: 500; } .post-thread-node-replied { font-style: italic; } +.post-thread-node-seen { + white-space: nowrap; +} + .post-thread-node-text { white-space: pre-wrap; word-break: break-word; diff --git a/src/components/post-thread-modal/post-thread-node.js b/src/components/post-thread-modal/post-thread-node.js index 1d3b440..e569a53 100644 --- a/src/components/post-thread-modal/post-thread-node.js +++ b/src/components/post-thread-modal/post-thread-node.js @@ -5,10 +5,30 @@ import React from 'react' import { Link } from 'react-router-dom' -function formatSeen (seen) { +import PostThreadAvatar from './post-thread-avatar' + +function formatRelativeSeen (seen) { if (!seen) return '' const ms = seen > 1e12 ? seen : seen * 1000 - return new Date(ms).toLocaleString() + const diff = Date.now() - ms + const seconds = Math.floor(diff / 1000) + + if (seconds < 60) return 'just now' + + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m` + + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h` + + const days = Math.floor(hours / 24) + if (days < 30) return `${days}d` + + const months = Math.floor(days / 30) + if (months < 12) return `${months}mo` + + const years = Math.floor(months / 12) + return `${years}y` } function truncateAddr (addr, maxLen = 20) { @@ -17,29 +37,52 @@ function truncateAddr (addr, maxLen = 20) { return `${addr.slice(0, half)}...${addr.slice(-half)}` } -function PostThreadNode ({ post, depth = 0, isRoot = false }) { +function getDisplayName (addr, profiles) { + const profile = profiles?.[addr] + if (profile?.name) { + return profile.name + } + return truncateAddr(addr, 24) +} + +function PostThreadNode ({ post, profiles = {}, depth = 0, isRoot = false }) { if (!post) return null + const profile = profiles[post.addr] || {} + const displayName = getDisplayName(post.addr, profiles) + const hasCustomName = Boolean(profile.name) + return (
0 ? `${Math.min(depth, 8) * 1.25}rem` : undefined }} >
-
- - {truncateAddr(post.addr, 24)} - - {!isRoot && replied} - {formatSeen(post.seen)} +
+ +
+ + {displayName} + + {!isRoot && replied} + {formatRelativeSeen(post.seen)} +
{post.text}
{(post.replies || []).map((reply) => ( - + ))}
diff --git a/src/components/post-thread-modal/thread-profiles.js b/src/components/post-thread-modal/thread-profiles.js new file mode 100644 index 0000000..a2e9ec3 --- /dev/null +++ b/src/components/post-thread-modal/thread-profiles.js @@ -0,0 +1,36 @@ +/* + Helpers for loading display names and avatars for thread participants. +*/ + +export function collectThreadAddrs (post) { + const addrs = new Set() + + function walk (node) { + if (!node?.addr) return + addrs.add(node.addr) + for (const reply of node.replies || []) { + walk(reply) + } + } + + walk(post) + return [...addrs] +} + +export async function loadThreadProfiles (addrs, memoDb) { + const profiles = {} + + await Promise.all(addrs.map(async (addr) => { + const [nameRecord, profilePic] = await Promise.all([ + memoDb.getName(addr), + memoDb.getProfilePic(addr) + ]) + + profiles[addr] = { + name: nameRecord?.name || null, + profilePicUrl: profilePic?.url || null + } + })) + + return profiles +} diff --git a/src/services/memo-db.js b/src/services/memo-db.js index 251ed0f..fea9537 100644 --- a/src/services/memo-db.js +++ b/src/services/memo-db.js @@ -66,6 +66,21 @@ class MemoDb { } } + async getName (addr) { + try { + const result = await this.axios.get( + `${config.backend}/level/name/${encodeURIComponent(addr)}` + ) + return result.data + } catch (err) { + if (err.response && err.response.status === 404) { + return null + } + console.error('Error in getName()') + throw err + } + } + async getPostsByAddr (addr, { limit = 100, offset = 0 } = {}) { try { const result = await this.axios.get( From 46c03a71b5bee9ed8c28538aa1c414492ab72e65 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Tue, 14 Jul 2026 19:05:02 -0700 Subject: [PATCH 5/5] checking in latest changes --- src/components/app-body/posts/index.js | 91 +++++---------- src/components/post-feed/post-display.js | 45 ++++++++ src/components/post-feed/post-feed-item.js | 94 +++++++++++++++ src/components/post-feed/post-feed.css | 109 ++++++++++++++++++ src/components/post-thread-modal/index.js | 1 + .../post-thread-modal/post-thread-node.js | 69 ++--------- .../post-thread-modal/thread-profiles.js | 4 + 7 files changed, 290 insertions(+), 123 deletions(-) create mode 100644 src/components/post-feed/post-display.js create mode 100644 src/components/post-feed/post-feed-item.js create mode 100644 src/components/post-feed/post-feed.css diff --git a/src/components/app-body/posts/index.js b/src/components/app-body/posts/index.js index b974758..345161d 100644 --- a/src/components/app-body/posts/index.js +++ b/src/components/app-body/posts/index.js @@ -4,35 +4,26 @@ // Global npm libraries import React, { useState, useEffect } from 'react' -import { Link } from 'react-router-dom' -import { Container, Row, Col, Spinner, Table, Button } from 'react-bootstrap' +import { Container, Row, Col, Spinner, Button } from 'react-bootstrap' // Local libraries import MemoDb from '../../../services/memo-db' -import AppUtil from '../../../util' -import PostReplyCount from '../../post-reply-count' +import PostFeedItem from '../../post-feed/post-feed-item' import PostThreadModal from '../../post-thread-modal' +import { + collectPostAddrs, + loadThreadProfiles +} from '../../post-thread-modal/thread-profiles' import '../../../App.css' +import '../../post-feed/post-feed.css' -const appUtil = new AppUtil() const PAGE_SIZE = 100 -function truncate (str, maxLen = 16) { - if (!str || str.length <= maxLen) return str - const half = Math.floor((maxLen - 3) / 2) - return `${str.slice(0, half)}...${str.slice(-half)}` -} - -function formatSeen (seen) { - if (!seen) return '' - const ms = seen > 1e12 ? seen : seen * 1000 - return new Date(ms).toLocaleString() -} - function RecentPosts () { const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [posts, setPosts] = useState([]) + const [profiles, setProfiles] = useState({}) const [pagination, setPagination] = useState(null) const [offset, setOffset] = useState(0) const [threadTxid, setThreadTxid] = useState(null) @@ -52,15 +43,22 @@ function RecentPosts () { const loadPosts = async () => { setLoading(true) setError(null) + setProfiles({}) try { const memoDb = new MemoDb() const data = await memoDb.getRecentPosts({ limit: PAGE_SIZE, offset }) - setPosts(data.posts || []) + const loadedPosts = data.posts || [] + const addrs = collectPostAddrs(loadedPosts) + const profileMap = await loadThreadProfiles(addrs, memoDb) + + setPosts(loadedPosts) + setProfiles(profileMap) setPagination(data.pagination || null) } catch (err) { setError(err.message || 'Failed to load recent posts') setPosts([]) + setProfiles({}) setPagination(null) } @@ -105,51 +103,18 @@ function RecentPosts () {
)} - {!loading && !error && ( - - - - - - - - - - - - {posts.map((post) => ( - - - - - - - - ))} - -
AddressPostBlockSeenTXID
- - {truncate(post.addr, 24)} - - -
{post.text}
- openThread(post.txid)} - /> -
{post.blockHeight}{formatSeen(post.seen)} - appUtil.copyToClipboard(post.txid)} - > - {truncate(post.txid, 20)} - -
+ {!loading && !error && posts.length > 0 && ( +
+ {posts.map((post) => ( + openThread(post.txid)} + showFooterMeta + /> + ))} +
)} {!loading && !error && (pagination || offset > 0) && ( diff --git a/src/components/post-feed/post-display.js b/src/components/post-feed/post-display.js new file mode 100644 index 0000000..2d2b7c6 --- /dev/null +++ b/src/components/post-feed/post-display.js @@ -0,0 +1,45 @@ +/* + Shared display helpers for post feed and thread views. +*/ + +export function formatRelativeSeen (seen) { + if (!seen) return '' + const ms = seen > 1e12 ? seen : seen * 1000 + const diff = Date.now() - ms + const seconds = Math.floor(diff / 1000) + + if (seconds < 60) return 'just now' + + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m` + + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h` + + const days = Math.floor(hours / 24) + if (days < 30) return `${days}d` + + const months = Math.floor(days / 30) + if (months < 12) return `${months}mo` + + const years = Math.floor(months / 12) + return `${years}y` +} + +export 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)}` +} + +export function truncateTxid (txid, maxLen = 20) { + return truncateAddr(txid, maxLen) +} + +export function getDisplayName (addr, profiles) { + const profile = profiles?.[addr] + if (profile?.name) { + return profile.name + } + return truncateAddr(addr, 24) +} diff --git a/src/components/post-feed/post-feed-item.js b/src/components/post-feed/post-feed-item.js new file mode 100644 index 0000000..c601c10 --- /dev/null +++ b/src/components/post-feed/post-feed-item.js @@ -0,0 +1,94 @@ +/* + Single post row for feed and thread views. +*/ + +import React from 'react' +import { Link } from 'react-router-dom' + +import AppUtil from '../../util' +import PostReplyCount from '../post-reply-count' +import PostThreadAvatar from '../post-thread-modal/post-thread-avatar' +import { + formatRelativeSeen, + getDisplayName, + truncateTxid +} from './post-display' +import './post-feed.css' + +const appUtil = new AppUtil() + +function PostFeedItem ({ + post, + profile = {}, + profiles = {}, + onReplyClick, + showRepliedLabel = false, + showFooterMeta = false, + showReplyCount = true, + embedded = false +}) { + if (!post) return null + + const displayName = getDisplayName(post.addr, profiles) + const hasCustomName = Boolean(profile.name ?? profiles?.[post.addr]?.name) + const Wrapper = embedded ? 'div' : 'article' + + return ( + +
+ +
+ + {displayName} + + {showRepliedLabel && ( + replied + )} + {formatRelativeSeen(post.seen)} +
+
+ +
{post.text}
+ + {showReplyCount && ( +
+ +
+ )} + + {showFooterMeta && ( +
+ Block {post.blockHeight} + · + appUtil.copyToClipboard(post.txid)} + role='button' + tabIndex={0} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + appUtil.copyToClipboard(post.txid) + } + }} + > + {truncateTxid(post.txid, 20)} + +
+ )} +
+ ) +} + +export default PostFeedItem diff --git a/src/components/post-feed/post-feed.css b/src/components/post-feed/post-feed.css new file mode 100644 index 0000000..bed0d58 --- /dev/null +++ b/src/components/post-feed/post-feed.css @@ -0,0 +1,109 @@ +.posts-feed { + max-width: 720px; + background: #f5f5f5; + border: 1px solid #e8e8e8; + border-radius: 4px; + overflow: hidden; +} + +.posts-feed-item { + background: #fff; + padding: 0.85rem 1rem; + border-bottom: 1px solid #e8e8e8; +} + +.posts-feed-item:last-child { + border-bottom: none; +} + +.posts-feed-item-embedded { + background: transparent; + padding: 0; + border-bottom: none; + margin-bottom: 0.75rem; +} + +.posts-feed-item-embedded:last-child { + margin-bottom: 0; +} + +.posts-feed-item-header { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.5rem; +} + +.posts-feed-item-meta { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.35rem; + font-size: 0.85rem; + color: #6c757d; + min-width: 0; +} + +.posts-feed-item-author { + font-weight: 600; + color: #212529; + text-decoration: none; +} + +.posts-feed-item-author:hover { + text-decoration: underline; + color: #28a745; +} + +.posts-feed-item-author-address { + font-family: monospace; + font-size: 0.8rem; + font-weight: 500; +} + +.posts-feed-item-replied { + font-style: italic; +} + +.posts-feed-item-seen { + white-space: nowrap; +} + +.posts-feed-item-text { + white-space: pre-wrap; + word-break: break-word; + font-size: 0.95rem; + line-height: 1.5; +} + +.posts-feed-item-actions { + margin-top: 0.25rem; +} + +.posts-feed-item-actions .post-reply-count { + margin-top: 0.35rem; +} + +.posts-feed-item-footer { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem; + margin-top: 0.5rem; + font-size: 0.75rem; + color: #adb5bd; +} + +.posts-feed-item-footer-separator { + color: #ced4da; +} + +.posts-feed-item-txid { + font-family: monospace; + cursor: pointer; +} + +.posts-feed-item-txid:hover { + color: #6c757d; + text-decoration: underline; +} diff --git a/src/components/post-thread-modal/index.js b/src/components/post-thread-modal/index.js index 4e97961..0effd1c 100644 --- a/src/components/post-thread-modal/index.js +++ b/src/components/post-thread-modal/index.js @@ -9,6 +9,7 @@ import MemoDb from '../../services/memo-db' import PostThreadNode from './post-thread-node' import { collectThreadAddrs, loadThreadProfiles } from './thread-profiles' import './post-thread-modal.css' +import '../post-feed/post-feed.css' function PostThreadModal ({ show, txid, onHide }) { const [loading, setLoading] = useState(false) diff --git a/src/components/post-thread-modal/post-thread-node.js b/src/components/post-thread-modal/post-thread-node.js index e569a53..b4a7aa4 100644 --- a/src/components/post-thread-modal/post-thread-node.js +++ b/src/components/post-thread-modal/post-thread-node.js @@ -3,54 +3,13 @@ */ import React from 'react' -import { Link } from 'react-router-dom' -import PostThreadAvatar from './post-thread-avatar' - -function formatRelativeSeen (seen) { - if (!seen) return '' - const ms = seen > 1e12 ? seen : seen * 1000 - const diff = Date.now() - ms - const seconds = Math.floor(diff / 1000) - - if (seconds < 60) return 'just now' - - const minutes = Math.floor(seconds / 60) - if (minutes < 60) return `${minutes}m` - - const hours = Math.floor(minutes / 60) - if (hours < 24) return `${hours}h` - - const days = Math.floor(hours / 24) - if (days < 30) return `${days}d` - - const months = Math.floor(days / 30) - if (months < 12) return `${months}mo` - - const years = Math.floor(months / 12) - return `${years}y` -} - -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 getDisplayName (addr, profiles) { - const profile = profiles?.[addr] - if (profile?.name) { - return profile.name - } - return truncateAddr(addr, 24) -} +import PostFeedItem from '../post-feed/post-feed-item' function PostThreadNode ({ post, profiles = {}, depth = 0, isRoot = false }) { if (!post) return null const profile = profiles[post.addr] || {} - const displayName = getDisplayName(post.addr, profiles) - const hasCustomName = Boolean(profile.name) return (
0 ? `${Math.min(depth, 8) * 1.25}rem` : undefined }} >
-
- -
- - {displayName} - - {!isRoot && replied} - {formatRelativeSeen(post.seen)} -
-
-
{post.text}
+ {(post.replies || []).map((reply) => ( post.addr).filter(Boolean))] +} + export function collectThreadAddrs (post) { const addrs = new Set()