diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 7bab3af..31fe662 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -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 (.+)$/, diff --git a/psf-memo-client/acceptance/lib/render-recent-profile-account.js b/psf-memo-client/acceptance/lib/render-recent-profile-account.js new file mode 100644 index 0000000..0949bfa --- /dev/null +++ b/psf-memo-client/acceptance/lib/render-recent-profile-account.js @@ -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 } diff --git a/psf-memo-client/src/components/app-body/recent-profiles/index.js b/psf-memo-client/src/components/app-body/recent-profiles/index.js index de7c5c2..1a770d2 100644 --- a/psf-memo-client/src/components/app-body/recent-profiles/index.js +++ b/psf-memo-client/src/components/app-body/recent-profiles/index.js @@ -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 () {
| Address | -Bio | -Block | -Seen | -TXID | + {RECENT_PROFILES_TABLE_HEADERS.map((header) => ( +{header} | + ))}
|---|---|---|---|---|---|
|
+ |
{
+ 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
diff --git a/psf-memo-client/src/services/recent-profiles-table.js b/psf-memo-client/src/services/recent-profiles-table.js
new file mode 100644
index 0000000..323ab1d
--- /dev/null
+++ b/psf-memo-client/src/services/recent-profiles-table.js
@@ -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
+}
diff --git a/psf-memo-client/test/unit/recent-profile-account-component.test.js b/psf-memo-client/test/unit/recent-profile-account-component.test.js
new file mode 100644
index 0000000..d7394e4
--- /dev/null
+++ b/psf-memo-client/test/unit/recent-profile-account-component.test.js
@@ -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, / |