Merge account-avatar-display refactorer work

By architect.
This commit is contained in:
Chris Troutner
2026-09-05 13:09:38 -07:00
12 changed files with 392 additions and 37 deletions
@@ -53,6 +53,7 @@ const PollOptionPage = require('../../src/services/poll-option-page')
const MemoPollVote = require('../../src/services/memo-poll-vote')
const PollVotePage = require('../../src/services/poll-vote-page')
const { renderPostText } = require('./render-post')
const { renderAccountAvatar } = require('./render-account-avatar')
const { YOUTUBE_EMBED_BASE_URL } = require('../../src/services/youtube-embed')
const MEMO_POST_PREFIX = MemoPost.MEMO_POST_PREFIX
@@ -926,6 +927,41 @@ const handlers = [
}
}
},
{
name: 'account page displays avatar image',
pattern: /^the account page displays the avatar image with the URL "<([A-Za-z0-9_]+)>"$/,
run (m, example, world) {
const param = m[1]
const expected = example[param]
if (!world.accountPage.hasAvatarImage()) {
throw new Error('Account page does not have an avatar URL to display.')
}
const actual = world.accountPage.getAvatarImageUrl()
if (actual !== expected) {
throw new Error(`Expected avatar URL "${expected}", got "${actual}".`)
}
const html = renderAccountAvatar(actual)
if (!html.includes(`src="${expected}"`)) {
throw new Error(`Account page does not render an avatar image with src="${expected}".`)
}
if (!html.includes('<img')) {
throw new Error('Account page does not render an avatar image element.')
}
}
},
{
name: 'account page does not display avatar image',
pattern: /^the account page does not display an avatar image$/,
run (m, example, world) {
if (world.accountPage.hasAvatarImage()) {
throw new Error(`Account page unexpectedly has an avatar URL: ${world.accountPage.getAvatarImageUrl()}.`)
}
const html = renderAccountAvatar(world.accountPage.getAvatarImageUrl())
if (html.includes('<img')) {
throw new Error('Account page unexpectedly renders an avatar image element.')
}
}
},
{
name: 'click Set Name button',
pattern: /^I click the Set Name button$/,
@@ -0,0 +1,20 @@
/*
Acceptance rendering adapter for the account page avatar image.
Renders the same AvatarImage component the browser uses to a static HTML
string, so acceptance assertions can inspect the resulting image markup
without running a browser.
*/
'use strict'
const React = require('react')
const ReactDOMServer = require('react-dom/server')
const AvatarImage = require('../../src/components/account/avatar-image')
function renderAccountAvatar (url) {
const element = React.createElement(AvatarImage, { url })
return ReactDOMServer.renderToStaticMarkup(element)
}
module.exports = { renderAccountAvatar }
@@ -0,0 +1,29 @@
# Scenarios: Account Avatar Display - 1, Account Avatar Display - 2
#
# When the authenticated account has an avatar URL set, the /account page
# renders that image instead of only showing the URL as text. When no avatar
# URL is set, the account page shows no avatar image. This is a read-only
# rendering feature in psf-memo-client: it broadcasts no Memo action and
# changes no DB data.
Feature: Account Avatar Display
Background:
Given a wallet authenticated for the address bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d
Given the wallet has spendable output to pay the transaction fee
Scenario Outline: Account Avatar Display - 1 the account page displays the avatar image when an avatar URL is set
Given I navigate to the path /memo/set-avatar-url
When I type an avatar URL with the text "<url>"
When I submit the avatar URL
Then the app broadcasts an OP_RETURN transaction with the Memo set-profile-picture prefix
When I navigate to the path /account
Then the account page displays the avatar image with the URL "<url>"
Examples:
| url |
| https://example.com/avatar.png |
| https://cdn.example.com/pics/me.jpg |
Scenario: Account Avatar Display - 2 the account page shows no avatar image when no avatar URL is set
Given I navigate to the path /account
Then the account page does not display an avatar image
@@ -0,0 +1,22 @@
/*
Pure account avatar image component.
Renders an avatar <img> when a URL is provided and nothing when it is
absent. Written in plain React.createElement style so the same module can
be used by the JSX components in the browser build and by the acceptance
adapter that renders HTML under Node.
*/
const React = require('react')
function AvatarImage ({ url }) {
if (!url) return null
return React.createElement('img', {
src: url,
alt: 'Account avatar',
className: 'account-avatar-image'
})
}
module.exports = AvatarImage
@@ -11,6 +11,7 @@ import { useNavigate } from 'react-router-dom'
// Local libraries
import MemoDb from '../../../services/memo-db'
import AccountPage from '../../../services/account-page'
import AvatarImage from '../../../components/account/avatar-image'
import { truncateAddr } from '../../../util'
function Account (props) {
@@ -63,7 +64,7 @@ function Account (props) {
const displayName = accountPage.getName() || name || truncateAddr(address, 24)
const displayBio = accountPage.getBio() || bio || ''
const displayAvatarUrl = accountPage.getAvatarUrl() || avatarUrl || ''
const displayAvatarUrl = accountPage.getDisplayAvatarUrl(avatarUrl)
return (
<Container className='account-page mt-4'>
@@ -91,9 +92,15 @@ function Account (props) {
<strong>Bio: </strong>
{displayBio || <span className='text-muted'>No bio set</span>}
</p>
<p className='account-avatar-url'>
<strong>Avatar URL: </strong>
{displayAvatarUrl || <span className='text-muted'>No avatar URL set</span>}
<p className='account-avatar'>
<strong>Avatar: </strong>
{displayAvatarUrl
? (
<AvatarImage url={displayAvatarUrl} />
)
: (
<span className='text-muted'>No avatar URL set</span>
)}
</p>
<p className='account-address'>
<strong>Address: </strong>
@@ -53,6 +53,22 @@ class AccountPage {
return this._getProfileField('getAvatarUrl')
}
// The avatar URL to display, preferring the injected profile store and
// falling back to an optional externally loaded URL (e.g. from memo-db).
getDisplayAvatarUrl (fallbackUrl = null) {
return this.getAvatarUrl() || fallbackUrl || null
}
// Whether the account page should display an avatar image.
hasAvatarImage (fallbackUrl = null) {
return this.getDisplayAvatarUrl(fallbackUrl) !== null
}
// The URL for the account page avatar image, or null when none is set.
getAvatarImageUrl (fallbackUrl = null) {
return this.getDisplayAvatarUrl(fallbackUrl)
}
// Whether the account page exposes a Set Name button.
hasSetNameButton () {
return true
@@ -0,0 +1,92 @@
/*
Property tests for the account page avatar display logic.
The unit tests probe a few fixed inputs. These properties cover broad input
combinations so the invariants hold everywhere:
- precedence: getDisplayAvatarUrl prefers the profile store URL, then the
fallback URL, then null.
- consistency: hasAvatarImage is true exactly when getAvatarImageUrl is
non-null, and getAvatarImageUrl always equals getDisplayAvatarUrl.
- round trip: getAvatarUrl returns exactly the URL stored in the profile
store (or null when none is stored).
*/
'use strict'
const test = require('node:test')
const { seededRandom, forAll } = require('./harness')
const AccountPage = require('../../src/services/account-page')
const rng = seededRandom(20260905)
// A pool of plausible avatar URLs so generated inputs vary.
const URL_POOL = [
'https://example.com/avatar.png',
'https://cdn.example.com/pics/me.jpg',
'https://x.io/avatar.png',
'https://static.example.org/img/me.webp'
]
// Generate a URL or null.
function maybeUrl () {
if (rng() < 0.5) return null
return URL_POOL[Math.floor(rng() * URL_POOL.length)]
}
function makeProfiles () {
const avatarUrls = {}
return {
setAvatarUrl: (addr, url) => { avatarUrls[addr] = url },
getAvatarUrl: (addr) => avatarUrls[addr] || null
}
}
function makeWallet (address = 'bitcoincash:qqlrzp23w08434twmvr4fxw672whkjy0py26r63g3d') {
return { walletInfo: { cashAddress: address } }
}
// Build a page with an optional stored profile URL and return it.
function makePage (profileUrl) {
const profiles = makeProfiles()
const wallet = makeWallet()
const page = new AccountPage({ wallet, profiles })
if (profileUrl) profiles.setAvatarUrl(wallet.walletInfo.cashAddress, profileUrl)
return page
}
test('getDisplayAvatarUrl prefers the profile URL, then the fallback, then null', async () => {
await forAll(
(i) => ({ profileUrl: maybeUrl(), fallback: maybeUrl() }),
({ profileUrl, fallback }) => {
const page = makePage(profileUrl)
const expected = profileUrl || fallback || null
return page.getDisplayAvatarUrl(fallback) === expected
},
{ label: 'getDisplayAvatarUrl precedence' }
)
})
test('hasAvatarImage and getAvatarImageUrl are consistent with getDisplayAvatarUrl', async () => {
await forAll(
(i) => ({ profileUrl: maybeUrl(), fallback: maybeUrl() }),
({ profileUrl, fallback }) => {
const page = makePage(profileUrl)
const url = page.getAvatarImageUrl(fallback)
return page.hasAvatarImage(fallback) === (url !== null) &&
url === page.getDisplayAvatarUrl(fallback)
},
{ label: 'avatar image consistency' }
)
})
test('getAvatarUrl round-trips the stored profile URL', async () => {
await forAll(
(i) => maybeUrl(),
(url) => {
const page = makePage(url)
return page.getAvatarUrl() === url
},
{ label: 'getAvatarUrl round trip' }
)
})
@@ -0,0 +1,54 @@
/*
Unit tests for the account avatar image component.
The component renders an avatar image when a URL is provided and renders
nothing when the URL is missing, so the account page can display the
authenticated user's profile picture.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const React = require('react')
const ReactDOMServer = require('react-dom/server')
const AvatarImage = require('../../src/components/account/avatar-image')
function renderAvatarImage (url) {
const element = React.createElement(AvatarImage, { url })
return ReactDOMServer.renderToStaticMarkup(element)
}
test('renders null when the URL is undefined', () => {
const html = renderAvatarImage(undefined)
assert.equal(html, '')
})
test('renders null when the URL is null', () => {
const html = renderAvatarImage(null)
assert.equal(html, '')
})
test('renders null when the URL is an empty string', () => {
const html = renderAvatarImage('')
assert.equal(html, '')
})
test('renders an img element with the provided URL', () => {
const url = 'https://example.com/avatar.png'
const html = renderAvatarImage(url)
assert.match(html, /<img[^>]+src="https:\/\/example\.com\/avatar\.png"/)
})
test('renders an img element with an accessible alt text', () => {
const html = renderAvatarImage('https://example.com/avatar.png')
assert.match(html, /<img[^>]+alt="Account avatar"/)
})
test('renders an img element with the account avatar class', () => {
const html = renderAvatarImage('https://example.com/avatar.png')
assert.match(html, /<img[^>]+class="account-avatar-image"/)
})
@@ -145,3 +145,72 @@ test('clickSetAvatarUrl navigates to the set-avatar-url page', () => {
assert.deepEqual(navigated, [AccountPage.SET_AVATAR_URL_PATH])
})
test('hasAvatarImage returns true when an avatar URL is set', () => {
const profiles = makeProfiles()
const wallet = makeWallet()
const page = new AccountPage({ wallet, profiles })
profiles.setAvatarUrl(wallet.walletInfo.cashAddress, 'https://example.com/avatar.png')
assert.equal(page.hasAvatarImage(), true)
})
test('hasAvatarImage returns true when only a fallback URL is provided', () => {
const profiles = makeProfiles()
const wallet = makeWallet()
const page = new AccountPage({ wallet, profiles })
assert.equal(page.hasAvatarImage('https://fallback.com/avatar.png'), true)
})
test('hasAvatarImage returns false when no avatar URL is set', () => {
const profiles = makeProfiles()
const wallet = makeWallet()
const page = new AccountPage({ wallet, profiles })
assert.equal(page.hasAvatarImage(), false)
})
test('hasAvatarImage returns false without a wallet', () => {
const profiles = makeProfiles()
const page = new AccountPage({ profiles })
assert.equal(page.hasAvatarImage(), false)
})
test('getAvatarImageUrl returns the stored avatar URL', () => {
const profiles = makeProfiles()
const wallet = makeWallet()
const page = new AccountPage({ wallet, profiles })
profiles.setAvatarUrl(wallet.walletInfo.cashAddress, 'https://example.com/avatar.png')
assert.equal(page.getAvatarImageUrl(), 'https://example.com/avatar.png')
})
test('getAvatarImageUrl returns the fallback URL when no profile URL is set', () => {
const profiles = makeProfiles()
const wallet = makeWallet()
const page = new AccountPage({ wallet, profiles })
assert.equal(page.getAvatarImageUrl('https://fallback.com/avatar.png'), 'https://fallback.com/avatar.png')
})
test('getAvatarImageUrl returns null when no avatar URL is set', () => {
const profiles = makeProfiles()
const wallet = makeWallet()
const page = new AccountPage({ wallet, profiles })
assert.equal(page.getAvatarImageUrl(), null)
})
test('getDisplayAvatarUrl prefers the profile store over the fallback', () => {
const profiles = makeProfiles()
const wallet = makeWallet()
const page = new AccountPage({ wallet, profiles })
profiles.setAvatarUrl(wallet.walletInfo.cashAddress, 'https://profile.com/avatar.png')
assert.equal(page.getDisplayAvatarUrl('https://fallback.com/avatar.png'), 'https://profile.com/avatar.png')
})
+21 -11
View File
@@ -366,6 +366,15 @@ that a single user-facing feature may require specs in more than one component.
address on `:` and yielded just `"bitcoincash"` — strip the trailing
`:<followeePkHash>` suffix via `key.slice(0, key.lastIndexOf(':'))` instead.
Spec: `psf-memo-client/specs/mute-feed-filtering.feature`.
21. **The recent-feed `total` is now capped, not exact.** Since the
feed-query-performance job (`2bcc965`), `GET /posts/recent` computes
`total`/`hasMore` from a capped scan of the last `TOTAL_SCAN_CAP` (10)
top-level posts rather than a full `postHeights` walk. `total` is therefore
`min(actual, TOTAL_SCAN_CAP)` and `hasMore` is only reliable for the first
few pages; deep pagination past the cap may report `hasMore: false` even
when older posts exist. Specs for the recent feed should assert `total`
against the cap (e.g. `10`) and only assert `hasMore` for the first pages.
Spec: `psf-memo-db/specs/feed-query-performance.feature`.
---
@@ -403,14 +412,15 @@ At the end of each session, update this file:
- Note the current `master` HEAD commit.
- State the next feature to work on.
Current `master` HEAD: `3992395` (merged architect's mute-feed-filtering job —
muting a profile now hides its content from the viewer's recent feed, topic feed,
search results, and notifications. Server-side filtering keyed by the viewer's
address passed as a `viewer` query param; shared `loadMutedAddrs`/`isMutedPost`
helper in `psf-memo-db/src/adapters/lib/muted-posts.js`. Verified DB 343 unit +
40 property + 9 acceptance passing + lint clean; client build OK + 284 unit +
24 acceptance passing + lint clean, including the new `mute-feed-filtering`
suite).
Next action: **spec the feed query performance work** (capped scan for the
`total`/`hasMore` computation + per-page reply counting) — see
`specs/feature-backlog.md` "Next up: feed query performance".
Current `master` HEAD: `2bcc965` (merged architect's feed-query-performance job —
`GET /posts/recent` no longer does two full scans per request. Reply counts are
computed per returned post (`countRepliesForTxids`) instead of a global
`buildReplyCountMap()` scan, and the `total`/`hasMore` computation is a capped
scan of the last `TOTAL_SCAN_CAP` (10) top-level posts instead of walking the
whole `postHeights` index. `list-recent-posts.js` uses
`scanRecentPostTxidsAndCount()` which returns page txids plus a capped total in
one bounded scan. Verified DB unit + 10 acceptance passing + lint clean,
including the new `feed-query-performance` suite).
Next action: **TBD** — current direction is front-end improvements to
`psf-memo-client` (UI/UX polish, accessibility, performance, responsiveness,
state handling, error surfacing). See `specs/feature-backlog.md`.
+20 -17
View File
@@ -61,6 +61,16 @@ focus is **front-end improvements** to `psf-memo-client` (the React SPA).
of the raw URL; surrounding text is preserved; non-embeddable URLs stay plain
text. Client-only rendering feature. Spec:
`psf-memo-client/specs/youtube-embed.feature`. Merged to `master` at `b63019c`.
- **Feed query performance (2026-09-05):** `GET /posts/recent` no longer does
two full scans per request. Reply counts are computed per returned post
(`countRepliesForTxids`) instead of a global `buildReplyCountMap()` scan, and
the `total`/`hasMore` computation is a capped scan of the last
`TOTAL_SCAN_CAP` (10) top-level posts instead of walking the whole
`postHeights` index. `list-recent-posts.js` now uses
`scanRecentPostTxidsAndCount()` which returns the page txids plus a capped
total in one bounded scan. Spec:
`psf-memo-db/specs/feed-query-performance.feature`. Merged to `master` at
`2bcc965`.
---
@@ -137,25 +147,18 @@ Reference: https://memo.sv/protocol (Wayback snapshot 2025-12-15)
---
## Next up: feed query performance
## Next up: feed query performance — DONE (2026-09-05)
`GET /posts/recent` (and the other paginated feeds) is slow at 1.3M posts because
`list-recent-posts.js` does two full scans on every request:
`GET /posts/recent` previously did two full scans per request:
`countTopLevelPosts()` walked the entire `postHeights` index for
`total`/`hasMore`, and `buildReplyCountMap()` scanned all `postChildren` entries.
Both are now bounded: reply counts are per-page (`countRepliesForTxids`) and the
total scan is capped to the last `TOTAL_SCAN_CAP` (10) top-level posts. Merged
to `master` at `2bcc965`.
- `countTopLevelPosts()` iterates the ENTIRE `postHeights` index to compute the
`total`/`hasMore` pagination field.
- `buildReplyCountMap()` scans ALL `postChildren` entries to build a global
reply-count map.
Planned optimization (decision: **capped scan**, keep it simple):
- Replace the global `buildReplyCountMap()` with per-page-txid reply counting
(like `countLikesForTxids` already does) — only count replies for the ~50
posts on the page.
- Cap the `total` scan to the last N posts / last N blocks so `hasMore` still
works for the first pages without walking all 1.3M entries.
Affected components: `psf-memo-db` (feed use cases + post-query adapter).
Next feature: TBD — current direction is front-end improvements to
`psf-memo-client` (UI/UX polish, accessibility, performance, responsiveness,
state handling, error surfacing).
## Notes for future cycles
@@ -1,7 +1,7 @@
# Engineering Rules
## Startup Tools
- On startup, procure the latest version of each required CRAP, mutation, and DRY tool for the project language directly from the listed `github.com/unclebob/...` repositories and get each one ready to run.
- On startup, procure the latest version of each required CRAP, mutation, and DRY tool for the project language directly from the listed repositories and get each one ready to run.
- Resolve each listed repository at its latest available upstream version before installing or building it.
- Do not rely on stale cached, vendored, or preinstalled copies when a fresh GitHub install/build is possible in the current environment.
- Language tool table:
@@ -11,10 +11,7 @@
- Node.js JavaScript: install with `npm install`; mutation `github.com/FullStack-Agents/mutate4javascript`, CRAP `github.com/FullStack-Agents/crap4javascript`, DRY `github.com/FullStack-Agents/dry4javascript`.
## Language Defaults
- For Clojure projects, prefer Babashka where possible.
- For Clojure projects, prefer Speclj for unit and behavior tests.
- For Clojure or Babashka projects using Speclj, use `github.com/unclebob/speclj-structure-check` to validate test syntax. If a Speclj spec file changed, run the structure check before executing the relevant test command.
- For Java projects, avoid using Maven to run tests; build dedicated test runners and run those instead.
- For JavaScript projects, prefer these npm dev dependencies: mocha for test runner, chai for assertion library, standard for linting, c8 for code coverage, semantic-release for version control.
## Design And Testability
- Work in small, reviewable increments.