diff --git a/src/components/app-body/posts/index.js b/src/components/app-body/posts/index.js index f945223..345161d 100644 --- a/src/components/app-body/posts/index.js +++ b/src/components/app-body/posts/index.js @@ -4,60 +4,94 @@ // 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, Button } from 'react-bootstrap' // Local libraries import MemoDb from '../../../services/memo-db' -import AppUtil from '../../../util' +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() - -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() -} +const PAGE_SIZE = 100 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) + const [showThreadModal, setShowThreadModal] = useState(false) + + const openThread = (txid) => { + setThreadTxid(txid) + setShowThreadModal(true) + } + + const closeThread = () => { + setShowThreadModal(false) + setThreadTxid(null) + } useEffect(() => { const loadPosts = async () => { + setLoading(true) + setError(null) + setProfiles({}) + try { const memoDb = new MemoDb() - const data = await memoDb.getRecentPosts({ limit: 100, offset: 0 }) - setPosts(data.posts || []) + const data = await memoDb.getRecentPosts({ limit: PAGE_SIZE, offset }) + 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) } + 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}

} @@ -69,48 +103,46 @@ function RecentPosts () { )} - {!loading && !error && ( - - - - - - - - - - - - {posts.map((post) => ( - - - - - - - - ))} - -
AddressPostBlockSeenTXID
- - {truncate(post.addr, 24)} - - {post.text}{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/app-body/profile/index.js b/src/components/app-body/profile/index.js index 54dc62c..fb1b623 100644 --- a/src/components/app-body/profile/index.js +++ b/src/components/app-body/profile/index.js @@ -8,6 +8,8 @@ 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 PostThreadModal from '../../post-thread-modal' import '../../../App.css' import './profile.css' @@ -52,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 () => { @@ -137,12 +151,22 @@ function Profile () { Block {post.blockHeight} {post.text} + openThread(post.txid)} + /> ))} )} + + ) } 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-reply-count/index.js b/src/components/post-reply-count/index.js new file mode 100644 index 0000000..cf17f07 --- /dev/null +++ b/src/components/post-reply-count/index.js @@ -0,0 +1,39 @@ +/* + 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, 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} +
+ ) +} + +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..c9c9f3b --- /dev/null +++ b/src/components/post-reply-count/post-reply-count.css @@ -0,0 +1,20 @@ +.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; +} + +.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..0effd1c --- /dev/null +++ b/src/components/post-thread-modal/index.js @@ -0,0 +1,102 @@ +/* + 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 { 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) + const [error, setError] = useState(null) + const [thread, setThread] = useState(null) + const [profiles, setProfiles] = useState({}) + + useEffect(() => { + if (!show || !txid) { + return undefined + } + + let cancelled = false + + const loadThread = async () => { + setLoading(true) + setError(null) + setThread(null) + setProfiles({}) + + try { + const memoDb = new MemoDb() + const data = await memoDb.getPostThread(txid) + 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) { + 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) + setProfiles({}) + 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-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 new file mode 100644 index 0000000..af22921 --- /dev/null +++ b/src/components/post-thread-modal/post-thread-modal.css @@ -0,0 +1,83 @@ +.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 { + 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-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; + 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..b4a7aa4 --- /dev/null +++ b/src/components/post-thread-modal/post-thread-node.js @@ -0,0 +1,41 @@ +/* + Single post node in a reply thread (recursive). +*/ + +import React from 'react' + +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] || {} + + return ( +
0 ? `${Math.min(depth, 8) * 1.25}rem` : undefined }} + > +
+ + {(post.replies || []).map((reply) => ( + + ))} +
+
+ ) +} + +export default PostThreadNode 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..eb879e2 --- /dev/null +++ b/src/components/post-thread-modal/thread-profiles.js @@ -0,0 +1,40 @@ +/* + Helpers for loading display names and avatars for thread participants. +*/ + +export function collectPostAddrs (posts) { + return [...new Set((posts || []).map((post) => post.addr).filter(Boolean))] +} + +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 1704aec..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( @@ -79,6 +94,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