mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-21 16:52:01 -07:00
Implement recent profile identity join and Account column
DB: GET /profile/recent joins each profile's display name (names store) and avatar URL (profilePics store) by address, reporting null when a record is absent. The join runs on the requested page only and leaves the profile order and pagination unchanged. Client: the /profile/recent table gains a leftmost Account column showing the display name and avatar, both linking to the profile, with a truncated-address fallback when the name is absent and an identicon fallback when the avatar is absent. The existing columns are preserved. Add focused unit tests, acceptance fixtures, and regex step handlers for both components. By coder.
This commit is contained in:
@@ -45,6 +45,7 @@ const TopicFeedPage = require('../../src/services/topic-feed-page')
|
||||
const SearchPage = require('../../src/services/search-page')
|
||||
const NotificationsPage = require('../../src/services/notifications-page')
|
||||
const RecentProfilesPage = require('../../src/services/recent-profiles-page')
|
||||
const { buildRecentProfilesTable } = require('../../src/services/recent-profiles-table')
|
||||
const MemoTopicFollow = require('../../src/services/memo-topic-follow')
|
||||
const MemoTopicPost = require('../../src/services/memo-topic-post')
|
||||
const TopicPostPage = require('../../src/services/topic-post-page')
|
||||
@@ -60,6 +61,7 @@ const { renderPostOptions } = require('./render-post-options')
|
||||
const { renderLikeResult } = require('./render-like-result')
|
||||
const { renderMuteResult } = require('./render-mute-result')
|
||||
const { renderNotificationEntry } = require('./render-notification-entry')
|
||||
const { renderRecentProfileAccount } = require('./render-recent-profile-account')
|
||||
const { VIEW_POST_LABEL } = require('../../src/services/notification-entry')
|
||||
const PostOptions = require('../../src/services/post-options')
|
||||
const { YOUTUBE_EMBED_BASE_URL } = require('../../src/services/youtube-embed')
|
||||
@@ -714,6 +716,36 @@ function isTopicFeedActive (world) {
|
||||
return Boolean(world.currentPath && String(world.currentPath).startsWith('/topics/'))
|
||||
}
|
||||
|
||||
// Fixture "recent-profiles-identities" from recent-profile-display.feature:
|
||||
// the GET /profile/recent response already carrying each profile's display
|
||||
// name (name) and avatar URL (profilePicUrl), null when absent.
|
||||
function loadRecentProfilesFixture (world, name) {
|
||||
if (name !== 'recent-profiles-identities') {
|
||||
throw new Error(`Unknown recent profiles fixture: ${name}`)
|
||||
}
|
||||
|
||||
const fixture = [
|
||||
{ addr: 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy', text: 'alice bio', name: 'alice', profilePicUrl: 'https://example.com/alice.png' },
|
||||
{ addr: 'bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r', text: 'bob bio', name: 'bob', profilePicUrl: 'https://example.com/bob.jpg' },
|
||||
{ addr: 'bitcoincash:qpm2qsznhks23z7629mms6s4cwef74vcwvy22gdx6a', text: 'carol bio', name: 'carol', profilePicUrl: null },
|
||||
{ addr: 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d', text: 'dave bio', name: null, profilePicUrl: 'https://example.com/dave.png' }
|
||||
]
|
||||
|
||||
for (const profile of fixture) {
|
||||
world.memoDb.profiles.push(profile)
|
||||
}
|
||||
}
|
||||
|
||||
// The account view model for the recent profile with the given address.
|
||||
function recentProfileAccountFor (world, addr) {
|
||||
const table = buildRecentProfilesTable(world.recentProfilesPage.profiles)
|
||||
const row = table.rows.find((candidate) => candidate.addr === addr)
|
||||
if (!row) {
|
||||
throw new Error(`No recent profiles account for the address ${addr}.`)
|
||||
}
|
||||
return row.account
|
||||
}
|
||||
|
||||
// Handler registry. Each entry: { pattern, run }.
|
||||
// run receives (match, exampleStore, world, step).
|
||||
const handlers = [
|
||||
@@ -3764,6 +3796,76 @@ const handlers = [
|
||||
world.currentPath = RecentProfilesPage.RECENT_PROFILES_PATH
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'API serves recent profiles fixture',
|
||||
pattern: /^the psf-memo-db API serves the recent profiles fixture "([^"]+)"$/,
|
||||
run (m, example, world) {
|
||||
loadRecentProfilesFixture(world, m[1])
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'recent profiles account shows identity field',
|
||||
pattern: /^the recent profiles account for the address (.+) shows the (display name|avatar) "([^"]*)"$/,
|
||||
run (m, example, world) {
|
||||
const addr = resolveParam(m[1], example)
|
||||
const field = m[2] === 'display name' ? 'displayName' : 'avatarUrl'
|
||||
const expected = resolveParam(m[3], example)
|
||||
const account = recentProfileAccountFor(world, addr)
|
||||
const actual = account[field] ?? null
|
||||
if (actual !== expected) {
|
||||
throw new Error(`Expected the recent profiles account for ${addr} to show ${m[2]} "${expected}", got "${actual}".`)
|
||||
}
|
||||
const html = renderRecentProfileAccount(account)
|
||||
const rendered = m[2] === 'avatar' ? `src="${expected}"` : `>${expected}<`
|
||||
if (!html.includes(rendered)) {
|
||||
throw new Error(`Rendered recent profiles account does not show ${m[2]} "${expected}".`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'recent profiles account shows identicon',
|
||||
pattern: /^the recent profiles account for the address (.+) shows an identicon avatar$/,
|
||||
run (m, example, world) {
|
||||
const addr = resolveParam(m[1], example)
|
||||
const account = recentProfileAccountFor(world, addr)
|
||||
if (account.avatarUrl) {
|
||||
throw new Error(`Expected the recent profiles account for ${addr} to fall back to an identicon, got avatar "${account.avatarUrl}".`)
|
||||
}
|
||||
const html = renderRecentProfileAccount(account)
|
||||
if (!html.includes('recent-profile-identicon') || !html.includes('data-jdenticon-value')) {
|
||||
throw new Error('Rendered recent profiles account does not show an identicon avatar.')
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'recent profiles account links identity to profile',
|
||||
pattern: /^the recent profiles account for the address (.+) links the (avatar|display name) to "([^"]*)"$/,
|
||||
run (m, example, world) {
|
||||
const addr = resolveParam(m[1], example)
|
||||
const linkField = m[2]
|
||||
const expected = resolveParam(m[3], example)
|
||||
const account = recentProfileAccountFor(world, addr)
|
||||
if (account.profilePath !== expected) {
|
||||
throw new Error(`Expected the recent profiles account for ${addr} to link the ${linkField} to "${expected}", got "${account.profilePath}".`)
|
||||
}
|
||||
const className = linkField === 'avatar' ? 'recent-profile-avatar-link' : 'recent-profile-name-link'
|
||||
const href = anchorHref(renderRecentProfileAccount(account), className)
|
||||
if (href !== expected) {
|
||||
throw new Error(`Rendered recent profiles ${linkField} does not link to ${expected}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'recent profiles table has column headers',
|
||||
pattern: /^the recent profiles table has the column headers (.+)$/,
|
||||
run (m, example, world) {
|
||||
const expected = [...m[1].matchAll(/"([^"]*)"/g)].map((match) => match[1])
|
||||
const table = buildRecentProfilesTable(world.recentProfilesPage.profiles)
|
||||
if (table.headers.join('|') !== expected.join('|')) {
|
||||
throw new Error(`Expected recent profiles headers ${expected.join(', ')}, got ${table.headers.join(', ')}.`)
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'open profile page for address',
|
||||
pattern: /^I open the profile page for the address (.+)$/,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
Acceptance rendering adapter for the Recent Profiles account cell.
|
||||
|
||||
Renders the same RecentProfileAccount component the browser uses to a static
|
||||
HTML string, so acceptance assertions can inspect the avatar, profile links,
|
||||
and display name without running a browser.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const React = require('react')
|
||||
const ReactDOMServer = require('react-dom/server')
|
||||
const RecentProfileAccount = require('../../src/components/app-body/recent-profiles/recent-profile-account')
|
||||
|
||||
function renderRecentProfileAccount (account) {
|
||||
const element = React.createElement(RecentProfileAccount, { account })
|
||||
return ReactDOMServer.renderToStaticMarkup(element)
|
||||
}
|
||||
|
||||
module.exports = { renderRecentProfileAccount }
|
||||
@@ -3,12 +3,14 @@
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Container, Row, Col, Spinner, Table, Button } from 'react-bootstrap'
|
||||
|
||||
// Local libraries
|
||||
import MemoDb from '../../../services/memo-db'
|
||||
import RecentProfilesPage from '../../../services/recent-profiles-page'
|
||||
import { RECENT_PROFILES_TABLE_HEADERS, buildRecentProfileAccount } from '../../../services/recent-profiles-table'
|
||||
import RecentProfileAccount from './recent-profile-account'
|
||||
import AppUtil, { truncateAddr, truncateTxid } from '../../../util'
|
||||
import '../../../App.css'
|
||||
|
||||
@@ -22,6 +24,7 @@ function formatSeen (seen) {
|
||||
}
|
||||
|
||||
function RecentProfiles () {
|
||||
const navigate = useNavigate()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [profiles, setProfiles] = useState([])
|
||||
@@ -85,16 +88,17 @@ function RecentProfiles () {
|
||||
<Table striped bordered hover responsive className='mt-3'>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Address</th>
|
||||
<th>Bio</th>
|
||||
<th>Block</th>
|
||||
<th>Seen</th>
|
||||
<th>TXID</th>
|
||||
{RECENT_PROFILES_TABLE_HEADERS.map((header) => (
|
||||
<th key={header}>{header}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{profiles.map((profile) => (
|
||||
<tr key={`${profile.addr}-${profile.txid}`}>
|
||||
<td>
|
||||
<RecentProfileAccount account={buildRecentProfileAccount(profile)} onProfileClick={navigate} />
|
||||
</td>
|
||||
<td>
|
||||
<Link
|
||||
to={`/profile/${encodeURIComponent(profile.addr)}`}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
The Account cell of the Recent Profiles table.
|
||||
|
||||
Shows the profile's display name and avatar, links both to the profile, and
|
||||
falls back to an identicon when the profile has no avatar. The display name
|
||||
itself already carries the truncated-address fallback from the view model.
|
||||
|
||||
Written in plain React.createElement style so the same module can be used by
|
||||
the JSX Recent Profiles page in the browser build and by the acceptance
|
||||
adapter that renders HTML under Node.
|
||||
*/
|
||||
|
||||
const React = require('react')
|
||||
const JdenticonModule = require('@chris.troutner/react-jdenticon')
|
||||
|
||||
const Jdenticon = JdenticonModule.default || JdenticonModule
|
||||
|
||||
// The profile's avatar: the profile picture when present, an identicon
|
||||
// otherwise.
|
||||
function RecentProfileAvatar ({ account }) {
|
||||
if (account.avatarUrl) {
|
||||
return React.createElement('img', {
|
||||
src: account.avatarUrl,
|
||||
alt: 'Account avatar',
|
||||
className: 'recent-profile-avatar',
|
||||
width: 36,
|
||||
height: 36
|
||||
})
|
||||
}
|
||||
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ className: 'recent-profile-avatar recent-profile-identicon' },
|
||||
React.createElement(Jdenticon, { value: account.addr, size: '36' })
|
||||
)
|
||||
}
|
||||
|
||||
function RecentProfileAccount ({ account, onProfileClick }) {
|
||||
if (!account) return null
|
||||
|
||||
const handleProfileClick = (event) => {
|
||||
if (!onProfileClick) return
|
||||
event.preventDefault()
|
||||
onProfileClick(account.profilePath)
|
||||
}
|
||||
|
||||
return React.createElement(
|
||||
'div',
|
||||
{ className: 'recent-profile-account' },
|
||||
React.createElement(
|
||||
'a',
|
||||
{
|
||||
className: 'recent-profile-avatar-link',
|
||||
href: account.profilePath,
|
||||
onClick: handleProfileClick,
|
||||
'aria-label': `View ${account.displayName}'s profile`
|
||||
},
|
||||
React.createElement(RecentProfileAvatar, { account })
|
||||
),
|
||||
React.createElement(
|
||||
'a',
|
||||
{
|
||||
className: 'recent-profile-name-link',
|
||||
href: account.profilePath,
|
||||
onClick: handleProfileClick,
|
||||
title: account.addr
|
||||
},
|
||||
account.displayName
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = RecentProfileAccount
|
||||
module.exports.RecentProfileAvatar = RecentProfileAvatar
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
Build the Recent Profiles page table view model.
|
||||
|
||||
The /profile/recent response already carries each profile's display name
|
||||
(name, joined from the names store) and avatar URL (profilePicUrl, joined
|
||||
from the profilePics store). The React Recent Profiles page renders the table
|
||||
from this model so the leftmost Account column — display name and avatar,
|
||||
both linking to the profile, with truncated-address and identicon fallbacks —
|
||||
stays testable without a DOM.
|
||||
*/
|
||||
|
||||
const { truncateAddr } = require('../util')
|
||||
|
||||
const PROFILE_PATH_PREFIX = '/profile'
|
||||
|
||||
// Column headers, left to right. Account is first; the other columns preserve
|
||||
// the pre-existing order.
|
||||
const RECENT_PROFILES_TABLE_HEADERS = ['Account', 'Address', 'Bio', 'Block', 'Seen', 'TXID']
|
||||
|
||||
function profilePath (addr) {
|
||||
return `${PROFILE_PATH_PREFIX}/${encodeURIComponent(addr)}`
|
||||
}
|
||||
|
||||
// The name to show in the account cell: the display name when present, the
|
||||
// truncated address otherwise.
|
||||
function accountDisplayName (addr, name) {
|
||||
return name || truncateAddr(addr, 24)
|
||||
}
|
||||
|
||||
// The account-cell view model for one profile.
|
||||
function buildRecentProfileAccount (profile = {}) {
|
||||
return {
|
||||
addr: profile.addr,
|
||||
displayName: accountDisplayName(profile.addr, profile.name),
|
||||
avatarUrl: profile.profilePicUrl || null,
|
||||
profilePath: profilePath(profile.addr)
|
||||
}
|
||||
}
|
||||
|
||||
// The table view model: the header row plus one row per profile.
|
||||
function buildRecentProfilesTable (profiles = []) {
|
||||
return {
|
||||
headers: [...RECENT_PROFILES_TABLE_HEADERS],
|
||||
rows: profiles.map((profile) => ({
|
||||
addr: profile.addr,
|
||||
account: buildRecentProfileAccount(profile)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROFILE_PATH_PREFIX,
|
||||
RECENT_PROFILES_TABLE_HEADERS,
|
||||
profilePath,
|
||||
accountDisplayName,
|
||||
buildRecentProfileAccount,
|
||||
buildRecentProfilesTable
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
Unit tests for the Recent Profile account cell renderer.
|
||||
|
||||
The account cell shows the profile's display name and avatar, links both to
|
||||
the profile, and falls back to an identicon when the profile has no avatar.
|
||||
Written against the same rendering seam the acceptance suite uses so the
|
||||
module stays covered by the standard unit suite.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const React = require('react')
|
||||
const ReactDOMServer = require('react-dom/server')
|
||||
const RecentProfileAccount = require('../../src/components/app-body/recent-profiles/recent-profile-account')
|
||||
|
||||
const ALICE = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
|
||||
const PROFILE_PATH = `/profile/${encodeURIComponent(ALICE)}`
|
||||
|
||||
function makeAccount (overrides = {}) {
|
||||
return {
|
||||
addr: ALICE,
|
||||
displayName: 'alice',
|
||||
avatarUrl: null,
|
||||
profilePath: PROFILE_PATH,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function render (account) {
|
||||
return ReactDOMServer.renderToStaticMarkup(
|
||||
React.createElement(RecentProfileAccount, { account })
|
||||
)
|
||||
}
|
||||
|
||||
test('renders nothing without an account', () => {
|
||||
assert.equal(render(null), '')
|
||||
})
|
||||
|
||||
test('renders the display name', () => {
|
||||
const html = render(makeAccount())
|
||||
|
||||
assert.ok(html.includes('alice'))
|
||||
})
|
||||
|
||||
test('renders the avatar image when the profile has an avatar URL', () => {
|
||||
const html = render(makeAccount({ avatarUrl: 'https://example.com/alice.png' }))
|
||||
|
||||
assert.match(html, /<img[^>]+src="https:\/\/example\.com\/alice\.png"/)
|
||||
assert.doesNotMatch(html, /recent-profile-identicon/)
|
||||
})
|
||||
|
||||
test('renders an identicon when the profile has no avatar URL', () => {
|
||||
const html = render(makeAccount({ avatarUrl: null }))
|
||||
|
||||
assert.match(html, /recent-profile-identicon/)
|
||||
assert.match(html, /data-jdenticon-value/)
|
||||
assert.doesNotMatch(html, /<img/)
|
||||
})
|
||||
|
||||
test('links the avatar and display name to the profile', () => {
|
||||
const html = render(makeAccount())
|
||||
|
||||
assert.ok(html.includes('class="recent-profile-avatar-link"'))
|
||||
assert.ok(html.includes('class="recent-profile-name-link"'))
|
||||
assert.equal(html.split(`href="${PROFILE_PATH}"`).length - 1, 2)
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
Unit tests for the Recent Profiles table view model.
|
||||
|
||||
The /profile/recent response already carries each profile's display name
|
||||
(name) and avatar URL (profilePicUrl), joined by the DB. These tests pin the
|
||||
pure table/account view model independent of the React shell: the leftmost
|
||||
Account column, its truncated-address fallback, and the preserved columns.
|
||||
*/
|
||||
|
||||
'use strict'
|
||||
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const {
|
||||
PROFILE_PATH_PREFIX,
|
||||
RECENT_PROFILES_TABLE_HEADERS,
|
||||
profilePath,
|
||||
accountDisplayName,
|
||||
buildRecentProfileAccount,
|
||||
buildRecentProfilesTable
|
||||
} = require('../../src/services/recent-profiles-table')
|
||||
|
||||
const ALICE = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
|
||||
const DAVE = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d'
|
||||
|
||||
test('profilePath encodes the address for the profile route', () => {
|
||||
assert.equal(profilePath(ALICE), `${PROFILE_PATH_PREFIX}/${encodeURIComponent(ALICE)}`)
|
||||
})
|
||||
|
||||
test('accountDisplayName prefers the display name', () => {
|
||||
assert.equal(accountDisplayName(ALICE, 'alice'), 'alice')
|
||||
})
|
||||
|
||||
test('accountDisplayName falls back to the truncated address without a name', () => {
|
||||
assert.equal(accountDisplayName(DAVE, null), 'bitcoincas...py26r63g3d')
|
||||
assert.equal(accountDisplayName(DAVE, ''), 'bitcoincas...py26r63g3d')
|
||||
})
|
||||
|
||||
test('buildRecentProfileAccount carries the name, avatar, and profile path', () => {
|
||||
const account = buildRecentProfileAccount({
|
||||
addr: ALICE,
|
||||
name: 'alice',
|
||||
profilePicUrl: 'https://example.com/alice.png'
|
||||
})
|
||||
|
||||
assert.equal(account.addr, ALICE)
|
||||
assert.equal(account.displayName, 'alice')
|
||||
assert.equal(account.avatarUrl, 'https://example.com/alice.png')
|
||||
assert.equal(account.profilePath, `/profile/${encodeURIComponent(ALICE)}`)
|
||||
})
|
||||
|
||||
test('buildRecentProfileAccount reports a null avatar when the profile has no picture', () => {
|
||||
const account = buildRecentProfileAccount({ addr: ALICE, name: 'alice', profilePicUrl: null })
|
||||
|
||||
assert.equal(account.avatarUrl, null)
|
||||
})
|
||||
|
||||
test('the table headers put Account first and preserve the existing columns', () => {
|
||||
assert.deepEqual(RECENT_PROFILES_TABLE_HEADERS, ['Account', 'Address', 'Bio', 'Block', 'Seen', 'TXID'])
|
||||
})
|
||||
|
||||
test('buildRecentProfilesTable builds one row per profile with its account', () => {
|
||||
const table = buildRecentProfilesTable([
|
||||
{ addr: ALICE, name: 'alice', profilePicUrl: 'https://example.com/alice.png' },
|
||||
{ addr: DAVE, name: null, profilePicUrl: 'https://example.com/dave.png' }
|
||||
])
|
||||
|
||||
assert.deepEqual(table.headers, RECENT_PROFILES_TABLE_HEADERS)
|
||||
assert.equal(table.rows.length, 2)
|
||||
assert.equal(table.rows[0].addr, ALICE)
|
||||
assert.equal(table.rows[0].account.displayName, 'alice')
|
||||
assert.equal(table.rows[1].account.displayName, 'bitcoincas...py26r63g3d')
|
||||
})
|
||||
Reference in New Issue
Block a user