mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo-client.git
synced 2026-09-21 16:52:02 -07:00
Merge pull request #1 from Permissionless-Software-Foundation/unstable
UI Development
This commit is contained in:
@@ -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 (
|
||||
<Container>
|
||||
<Row>
|
||||
<Col>
|
||||
<h1 className='mt-4'>Recent Posts</h1>
|
||||
{pagination && (
|
||||
{pagination && posts.length > 0 && (
|
||||
<p className='text-muted'>
|
||||
Showing {posts.length} of {pagination.total} posts
|
||||
Showing {pagination.offset + 1}–{pagination.offset + posts.length} of {pagination.total} posts
|
||||
</p>
|
||||
)}
|
||||
{pagination && posts.length === 0 && (
|
||||
<p className='text-muted'>No posts on this page.</p>
|
||||
)}
|
||||
|
||||
{error && <p className='text-danger'>{error}</p>}
|
||||
|
||||
@@ -69,48 +103,46 @@ 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>{post.text}</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) && (
|
||||
<div className='d-flex justify-content-between mt-3 mb-4'>
|
||||
<Button
|
||||
variant='outline-primary'
|
||||
onClick={handlePrevious}
|
||||
disabled={!canGoBack}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline-primary'
|
||||
onClick={handleNext}
|
||||
disabled={!canGoNext}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<PostThreadModal
|
||||
show={showThreadModal}
|
||||
txid={threadTxid}
|
||||
onHide={closeThread}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 () {
|
||||
<span className='profile-post-block ms-2'>Block {post.blockHeight}</span>
|
||||
</div>
|
||||
<Card.Text className='profile-post-text'>{post.text}</Card.Text>
|
||||
<PostReplyCount
|
||||
count={post.replyCount ?? 0}
|
||||
onClick={() => openThread(post.txid)}
|
||||
/>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
))}
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<PostThreadModal
|
||||
show={showThreadModal}
|
||||
txid={threadTxid}
|
||||
onHide={closeThread}
|
||||
/>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={`post-reply-count${clickable ? ' post-reply-count-clickable' : ''}`}
|
||||
title={title}
|
||||
aria-label={title}
|
||||
role={clickable ? 'button' : undefined}
|
||||
tabIndex={clickable ? 0 : undefined}
|
||||
onClick={clickable ? onClick : undefined}
|
||||
onKeyDown={clickable ? handleKeyDown : undefined}
|
||||
>
|
||||
<FontAwesomeIcon icon={faComment} className='post-reply-count-icon' />
|
||||
<span className='post-reply-count-number'>{count}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PostReplyCount
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<Modal show={show} onHide={handleHide} size='lg' scrollable centered>
|
||||
<Modal.Header closeButton>
|
||||
<Modal.Title>Post thread</Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body className='post-thread-modal-body'>
|
||||
{loading && (
|
||||
<div className='text-center my-4'>
|
||||
<Spinner animation='border' role='status' variant='primary'>
|
||||
<span className='visually-hidden'>Loading replies...</span>
|
||||
</Spinner>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && !loading && (
|
||||
<p className='text-danger mb-0'>{error}</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && thread && (
|
||||
<PostThreadNode post={thread} profiles={profiles} isRoot />
|
||||
)}
|
||||
</Modal.Body>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default PostThreadModal
|
||||
@@ -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 (
|
||||
<img
|
||||
src={profilePicUrl}
|
||||
alt=''
|
||||
className='post-thread-avatar'
|
||||
width={size}
|
||||
height={size}
|
||||
onError={() => setPicError(true)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='post-thread-avatar post-thread-avatar-jdenticon' style={{ width: size, height: size }}>
|
||||
<Jdenticon size={String(size)} value={addr} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PostThreadAvatar
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={`post-thread-node${isRoot ? ' post-thread-node-root' : ''}`}
|
||||
style={{ marginLeft: depth > 0 ? `${Math.min(depth, 8) * 1.25}rem` : undefined }}
|
||||
>
|
||||
<div className='post-thread-node-inner'>
|
||||
<PostFeedItem
|
||||
post={post}
|
||||
profile={profile}
|
||||
profiles={profiles}
|
||||
showRepliedLabel={!isRoot}
|
||||
showReplyCount={false}
|
||||
embedded
|
||||
/>
|
||||
{(post.replies || []).map((reply) => (
|
||||
<PostThreadNode
|
||||
key={reply.txid}
|
||||
post={reply}
|
||||
profiles={profiles}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PostThreadNode
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user