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 && (
-
-
-
- | Address |
- Post |
- Block |
- Seen |
- TXID |
-
-
-
- {posts.map((post) => (
-
- |
-
- {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()