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()