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(