Showing names and avatars in reply modal

This commit is contained in:
Chris Troutner
2026-06-06 07:40:33 -07:00
parent 0a3212e6ca
commit 2e8c3b7ea8
6 changed files with 199 additions and 20 deletions
+18 -3
View File
@@ -7,12 +7,14 @@ 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'
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) {
@@ -25,12 +27,24 @@ function PostThreadModal ({ show, txid, onHide }) {
setLoading(true)
setError(null)
setThread(null)
setProfiles({})
try {
const memoDb = new MemoDb()
const data = await memoDb.getPostThread(txid)
if (!cancelled) {
setThread(data.post || null)
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) {
@@ -53,6 +67,7 @@ function PostThreadModal ({ show, txid, onHide }) {
const handleHide = () => {
setThread(null)
setProfiles({})
setError(null)
onHide()
}
@@ -76,7 +91,7 @@ function PostThreadModal ({ show, txid, onHide }) {
)}
{!loading && !error && thread && (
<PostThreadNode post={thread} isRoot />
<PostThreadNode post={thread} profiles={profiles} isRoot />
)}
</Modal.Body>
</Modal>
@@ -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
@@ -22,24 +22,59 @@
}
.post-thread-node-header {
font-size: 0.85rem;
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-family: monospace;
font-size: 0.8rem;
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;
@@ -5,10 +5,30 @@
import React from 'react'
import { Link } from 'react-router-dom'
function formatSeen (seen) {
import PostThreadAvatar from './post-thread-avatar'
function formatRelativeSeen (seen) {
if (!seen) return ''
const ms = seen > 1e12 ? seen : seen * 1000
return new Date(ms).toLocaleString()
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) {
@@ -17,29 +37,52 @@ function truncateAddr (addr, maxLen = 20) {
return `${addr.slice(0, half)}...${addr.slice(-half)}`
}
function PostThreadNode ({ post, depth = 0, isRoot = false }) {
function getDisplayName (addr, profiles) {
const profile = profiles?.[addr]
if (profile?.name) {
return profile.name
}
return truncateAddr(addr, 24)
}
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
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'>
<div className='post-thread-node-header text-muted'>
<Link
to={`/profile/${encodeURIComponent(post.addr)}`}
className='post-thread-node-author'
title={post.addr}
>
{truncateAddr(post.addr, 24)}
</Link>
{!isRoot && <span className='post-thread-node-replied ms-1'>replied</span>}
<span className='post-thread-node-seen ms-2'>{formatSeen(post.seen)}</span>
<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>
{(post.replies || []).map((reply) => (
<PostThreadNode key={reply.txid} post={reply} depth={depth + 1} />
<PostThreadNode
key={reply.txid}
post={reply}
profiles={profiles}
depth={depth + 1}
/>
))}
</div>
</div>
@@ -0,0 +1,36 @@
/*
Helpers for loading display names and avatars for thread participants.
*/
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
}
+15
View File
@@ -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(