checking in latest changes

This commit is contained in:
Chris Troutner
2026-07-14 19:05:02 -07:00
parent 2e8c3b7ea8
commit 46c03a71b5
7 changed files with 290 additions and 123 deletions
+28 -63
View File
@@ -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 () {
</div>
)}
{!loading && !error && (
<Table striped bordered hover responsive className='mt-3'>
<thead>
<tr>
<th>Address</th>
<th>Post</th>
<th>Block</th>
<th>Seen</th>
<th>TXID</th>
</tr>
</thead>
<tbody>
{posts.map((post) => (
<tr key={post.txid}>
<td>
<Link
to={`/profile/${encodeURIComponent(post.addr)}`}
style={{ fontFamily: 'monospace' }}
title={post.addr}
>
{truncate(post.addr, 24)}
</Link>
</td>
<td>
<div>{post.text}</div>
<PostReplyCount
count={post.replyCount ?? 0}
onClick={() => openThread(post.txid)}
/>
</td>
<td>{post.blockHeight}</td>
<td>{formatSeen(post.seen)}</td>
<td>
<span
style={{ fontFamily: 'monospace', cursor: 'pointer' }}
title={post.txid}
onClick={() => appUtil.copyToClipboard(post.txid)}
>
{truncate(post.txid, 20)}
</span>
</td>
</tr>
))}
</tbody>
</Table>
{!loading && !error && posts.length > 0 && (
<div className='posts-feed mt-3'>
{posts.map((post) => (
<PostFeedItem
key={post.txid}
post={post}
profiles={profiles}
onReplyClick={() => openThread(post.txid)}
showFooterMeta
/>
))}
</div>
)}
{!loading && !error && (pagination || offset > 0) && (
+45
View File
@@ -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)
}
@@ -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 (
<Wrapper className={`posts-feed-item${embedded ? ' posts-feed-item-embedded' : ''}`}>
<div className='posts-feed-item-header'>
<PostThreadAvatar
addr={post.addr}
profilePicUrl={profile.profilePicUrl ?? profiles?.[post.addr]?.profilePicUrl}
/>
<div className='posts-feed-item-meta'>
<Link
to={`/profile/${encodeURIComponent(post.addr)}`}
className={`posts-feed-item-author${hasCustomName ? '' : ' posts-feed-item-author-address'}`}
title={post.addr}
>
{displayName}
</Link>
{showRepliedLabel && (
<span className='posts-feed-item-replied'>replied</span>
)}
<span className='posts-feed-item-seen'>{formatRelativeSeen(post.seen)}</span>
</div>
</div>
<div className='posts-feed-item-text'>{post.text}</div>
{showReplyCount && (
<div className='posts-feed-item-actions'>
<PostReplyCount
count={post.replyCount ?? 0}
onClick={onReplyClick}
/>
</div>
)}
{showFooterMeta && (
<div className='posts-feed-item-footer'>
<span>Block {post.blockHeight}</span>
<span className='posts-feed-item-footer-separator'>·</span>
<span
className='posts-feed-item-txid'
title={post.txid}
onClick={() => 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)}
</span>
</div>
)}
</Wrapper>
)
}
export default PostFeedItem
+109
View File
@@ -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;
}
@@ -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)
@@ -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 (
<div
@@ -58,24 +17,14 @@ function PostThreadNode ({ post, profiles = {}, depth = 0, isRoot = false }) {
style={{ marginLeft: depth > 0 ? `${Math.min(depth, 8) * 1.25}rem` : undefined }}
>
<div className='post-thread-node-inner'>
<div className='post-thread-node-header'>
<PostThreadAvatar
addr={post.addr}
profilePicUrl={profile.profilePicUrl}
/>
<div className='post-thread-node-meta'>
<Link
to={`/profile/${encodeURIComponent(post.addr)}`}
className={`post-thread-node-author${hasCustomName ? '' : ' post-thread-node-author-address'}`}
title={post.addr}
>
{displayName}
</Link>
{!isRoot && <span className='post-thread-node-replied'>replied</span>}
<span className='post-thread-node-seen'>{formatRelativeSeen(post.seen)}</span>
</div>
</div>
<div className='post-thread-node-text'>{post.text}</div>
<PostFeedItem
post={post}
profile={profile}
profiles={profiles}
showRepliedLabel={!isRoot}
showReplyCount={false}
embedded
/>
{(post.replies || []).map((reply) => (
<PostThreadNode
key={reply.txid}
@@ -2,6 +2,10 @@
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()