From 2e8c3b7ea8a55051639740b2dc45ab8816086278 Mon Sep 17 00:00:00 2001 From: Chris Troutner Date: Sat, 6 Jun 2026 07:40:33 -0700 Subject: [PATCH] Showing names and avatars in reply modal --- src/components/post-thread-modal/index.js | 21 +++++- .../post-thread-modal/post-thread-avatar.js | 35 +++++++++ .../post-thread-modal/post-thread-modal.css | 41 ++++++++++- .../post-thread-modal/post-thread-node.js | 71 +++++++++++++++---- .../post-thread-modal/thread-profiles.js | 36 ++++++++++ src/services/memo-db.js | 15 ++++ 6 files changed, 199 insertions(+), 20 deletions(-) create mode 100644 src/components/post-thread-modal/post-thread-avatar.js create mode 100644 src/components/post-thread-modal/thread-profiles.js diff --git a/src/components/post-thread-modal/index.js b/src/components/post-thread-modal/index.js index b89fbe1..4e97961 100644 --- a/src/components/post-thread-modal/index.js +++ b/src/components/post-thread-modal/index.js @@ -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 && ( - + )} diff --git a/src/components/post-thread-modal/post-thread-avatar.js b/src/components/post-thread-modal/post-thread-avatar.js new file mode 100644 index 0000000..871c331 --- /dev/null +++ b/src/components/post-thread-modal/post-thread-avatar.js @@ -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 ( + setPicError(true)} + /> + ) + } + + return ( +
+ +
+ ) +} + +export default PostThreadAvatar diff --git a/src/components/post-thread-modal/post-thread-modal.css b/src/components/post-thread-modal/post-thread-modal.css index 75139fc..af22921 100644 --- a/src/components/post-thread-modal/post-thread-modal.css +++ b/src/components/post-thread-modal/post-thread-modal.css @@ -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; diff --git a/src/components/post-thread-modal/post-thread-node.js b/src/components/post-thread-modal/post-thread-node.js index 1d3b440..e569a53 100644 --- a/src/components/post-thread-modal/post-thread-node.js +++ b/src/components/post-thread-modal/post-thread-node.js @@ -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 (
0 ? `${Math.min(depth, 8) * 1.25}rem` : undefined }} >
-
- - {truncateAddr(post.addr, 24)} - - {!isRoot && replied} - {formatSeen(post.seen)} +
+ +
+ + {displayName} + + {!isRoot && replied} + {formatRelativeSeen(post.seen)} +
{post.text}
{(post.replies || []).map((reply) => ( - + ))}
diff --git a/src/components/post-thread-modal/thread-profiles.js b/src/components/post-thread-modal/thread-profiles.js new file mode 100644 index 0000000..a2e9ec3 --- /dev/null +++ b/src/components/post-thread-modal/thread-profiles.js @@ -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 +} diff --git a/src/services/memo-db.js b/src/services/memo-db.js index 251ed0f..fea9537 100644 --- a/src/services/memo-db.js +++ b/src/services/memo-db.js @@ -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(