Implement notification entry display

Name each Notifications entry with the actor's Memo display name and
avatar resolved client-side, link both to the actor's profile, show the
full address as plain text, and add a View Post link for like and reply
notifications that opens the referenced post thread. Fall back to the
truncated address and an identicon when a profile is missing or its
lookup fails.

By coder.
This commit is contained in:
Chris Troutner
2026-09-18 12:20:52 -07:00
parent f4d94999b5
commit 521d434524
8 changed files with 662 additions and 29 deletions
+225 -2
View File
@@ -57,6 +57,8 @@ const { renderPostText } = require('./render-post')
const { renderAccountAvatar } = require('./render-account-avatar')
const { renderPostOptions } = require('./render-post-options')
const { renderLikeResult } = require('./render-like-result')
const { renderNotificationEntry } = require('./render-notification-entry')
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')
const { toPushBuffer } = require('../../src/services/memo-multipush')
@@ -219,6 +221,9 @@ function makeMemoDb () {
const topicCounts = new Map()
const topicFollow = new Map()
const topicMetadata = new Map()
const profileNames = new Map()
const profilePics = new Map()
const profileFailures = new Set()
return {
posts,
@@ -257,6 +262,25 @@ function makeMemoDb () {
addSearchProfile (profile) {
searchProfiles.push(profile)
},
addProfileName (addr, name) {
profileNames.set(addr, name)
},
addProfilePic (addr, url) {
profilePics.set(addr, url)
},
addProfileFailure (addr) {
profileFailures.add(addr)
},
async getName (addr) {
if (profileFailures.has(addr)) throw new Error('profile lookup failed')
const name = profileNames.get(addr)
return name ? { name } : null
},
async getProfilePic (addr) {
if (profileFailures.has(addr)) throw new Error('profile lookup failed')
const url = profilePics.get(addr)
return url ? { url } : null
},
addLike (like) {
likes.push({
txid: like.txid,
@@ -641,7 +665,7 @@ function resolveParam (value, example) {
function resolveText (value, example) {
const trimmed = String(value).trim()
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
return trimmed.slice(1, -1)
return resolveParam(trimmed.slice(1, -1), example)
}
return resolveParam(value, example)
}
@@ -845,7 +869,7 @@ const handlers = [
name: 'thread modal opens for post',
pattern: /^the thread modal opens for the post with txid (.+)$/,
run (m, example, world) {
const txid = m[1].trim()
const txid = resolveParam(m[1], example)
if (world.thread.rootTxid !== txid) {
throw new Error(`Expected thread modal to open for ${txid}, but current thread is ${world.thread.rootTxid}.`)
}
@@ -3228,6 +3252,188 @@ const handlers = [
}
}
},
{
name: 'API serves display name for address',
pattern: /^the psf-memo-db API serves the display name "([^"]*)" for the address (.+)$/,
run (m, example, world) {
const name = resolveParam(m[1], example)
const addr = resolveParam(m[2], example)
world.memoDb.addProfileName(addr, name)
}
},
{
name: 'API serves avatar for address',
pattern: /^the psf-memo-db API serves the avatar "([^"]*)" for the address (.+)$/,
run (m, example, world) {
const avatar = resolveParam(m[1], example)
const addr = resolveParam(m[2], example)
world.memoDb.addProfilePic(addr, avatar)
}
},
{
name: 'API fails to serve profile for address',
pattern: /^the psf-memo-db API fails to serve the profile for the address (.+)$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
world.memoDb.addProfileFailure(addr)
}
},
{
name: 'notification entry shows display name',
pattern: /^the notification entry from the address (.+) shows the display name "([^"]*)"$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const expected = resolveParam(m[2], example)
const entry = notificationEntryFor(world, addr)
if (entry.displayName !== expected) {
throw new Error(`Expected the notification entry from ${addr} to show the display name "${expected}", got "${entry.displayName}".`)
}
const html = renderNotificationEntry(entry)
if (!html.includes(expected)) {
throw new Error(`Rendered notification entry does not show the display name "${expected}".`)
}
}
},
{
name: 'notification entry shows avatar',
pattern: /^the notification entry from the address (.+) shows the avatar "([^"]*)"$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const expected = resolveParam(m[2], example)
const entry = notificationEntryFor(world, addr)
if (entry.avatarUrl !== expected) {
throw new Error(`Expected the notification entry from ${addr} to show the avatar "${expected}", got "${entry.avatarUrl}".`)
}
const html = renderNotificationEntry(entry)
if (!html.includes(`src="${expected}"`)) {
throw new Error(`Rendered notification entry does not show the avatar "${expected}".`)
}
}
},
{
name: 'notification entry shows address as plain text',
pattern: /^the notification entry from the address (.+) shows the address (.+) as plain text$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const shown = resolveParam(m[2], example)
const entry = notificationEntryFor(world, addr)
if (entry.addr !== shown) {
throw new Error(`Expected the notification entry from ${addr} to show the address ${shown}.`)
}
const html = renderNotificationEntry(entry)
if (!html.includes(`notification-entry-address">${shown}<`)) {
throw new Error(`Rendered notification entry does not show the address ${shown} as plain text.`)
}
}
},
{
name: 'notification entry links avatar to profile',
pattern: /^the notification entry from the address (.+) links the avatar to "([^"]*)"$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const expected = resolveParam(m[2], example)
const entry = notificationEntryFor(world, addr)
if (entry.profilePath !== expected) {
throw new Error(`Expected the notification entry from ${addr} to link the avatar to "${expected}", got "${entry.profilePath}".`)
}
const href = anchorHref(renderNotificationEntry(entry), 'notification-entry-avatar-link')
if (href !== expected) {
throw new Error(`Rendered notification avatar does not link to ${expected}.`)
}
}
},
{
name: 'notification entry links display name to profile',
pattern: /^the notification entry from the address (.+) links the display name to "([^"]*)"$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const expected = resolveParam(m[2], example)
const entry = notificationEntryFor(world, addr)
if (entry.profilePath !== expected) {
throw new Error(`Expected the notification entry from ${addr} to link the display name to "${expected}", got "${entry.profilePath}".`)
}
const href = anchorHref(renderNotificationEntry(entry), 'notification-entry-name-link')
if (href !== expected) {
throw new Error(`Rendered notification display name does not link to ${expected}.`)
}
}
},
{
name: 'notification entry offers View Post link',
pattern: /^the notification entry from the address (.+) offers a "View Post" link$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const entry = notificationEntryFor(world, addr)
if (!entry.showViewPost) {
throw new Error(`Expected the notification entry from ${addr} to offer a View Post link.`)
}
const html = renderNotificationEntry(entry)
const anchor = anchorsIn(html).find((a) => a.attrs.includes('notification-entry-view-post'))
if (!anchor || !anchor.text.includes(VIEW_POST_LABEL)) {
throw new Error('Rendered notification entry does not offer a View Post link.')
}
}
},
{
name: 'notification entry does not offer View Post link',
pattern: /^the notification entry from the address (.+) does not offer a "View Post" link$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const entry = notificationEntryFor(world, addr)
if (entry.showViewPost) {
throw new Error(`Expected the notification entry from ${addr} not to offer a View Post link.`)
}
if (renderNotificationEntry(entry).includes(VIEW_POST_LABEL)) {
throw new Error('Rendered notification entry unexpectedly offers a View Post link.')
}
}
},
{
name: 'notification entry shows reply text',
pattern: /^the notification entry from the address (.+) shows the reply text "([^"]*)"$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const expected = resolveParam(m[2], example)
const entry = notificationEntryFor(world, addr)
if (entry.text !== expected) {
throw new Error(`Expected the notification entry from ${addr} to show the reply text "${expected}", got "${entry.text}".`)
}
if (!renderNotificationEntry(entry).includes(expected)) {
throw new Error(`Rendered notification entry does not show the reply text "${expected}".`)
}
}
},
{
name: 'notification entry shows identicon avatar',
pattern: /^the notification entry from the address (.+) shows an identicon avatar$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const entry = notificationEntryFor(world, addr)
if (entry.avatarUrl) {
throw new Error(`Expected the notification entry from ${addr} to fall back to an identicon, got avatar "${entry.avatarUrl}".`)
}
const html = renderNotificationEntry(entry)
if (!html.includes('notification-entry-identicon') || !html.includes('data-jdenticon-value')) {
throw new Error('Rendered notification entry does not show an identicon avatar.')
}
}
},
{
name: 'click View Post link in notification',
pattern: /^I click the "View Post" link in the notification from the address (.+)$/,
run (m, example, world) {
const addr = resolveParam(m[1], example)
const entry = notificationEntryFor(world, addr)
if (!entry.showViewPost) {
throw new Error(`The notification entry from ${addr} does not offer a View Post link.`)
}
if (!entry.postTxid) {
throw new Error(`The notification entry from ${addr} has no referenced post txid.`)
}
world.thread.rootTxid = entry.postTxid
world.replyPage.setParent(entry.postTxid)
}
},
{
name: 'API serves N recent posts',
pattern: /^the psf-memo-db API serves (<[A-Za-z0-9_]+>) recent posts$/,
@@ -3840,6 +4046,23 @@ function anchorsIn (html) {
return anchors
}
// The href of the first rendered anchor carrying the given class, or null.
function anchorHref (html, className) {
const anchor = anchorsIn(html).find((a) => a.attrs.includes(className))
if (!anchor) return null
const match = /href="([^"]*)"/.exec(anchor.attrs)
return match ? match[1] : null
}
// The built view model for the notification whose actor is `addr`.
function notificationEntryFor (world, addr) {
const entry = world.notificationsPage.getEntryByAddr(addr)
if (!entry) {
throw new Error(`No notification entry from the address ${addr}.`)
}
return entry
}
// Extract the image tags from a rendered HTML string. The acceptance adapter
// renders a small, controlled HTML subset, so a regex match is sufficient.
function imagesIn (html) {
@@ -0,0 +1,20 @@
/*
Acceptance rendering adapter for a notification entry.
Renders the same NotificationEntry component the browser uses to a static
HTML string, so acceptance assertions can inspect the avatar, profile links,
address, and View Post link markup without running a browser.
*/
'use strict'
const React = require('react')
const ReactDOMServer = require('react-dom/server')
const NotificationEntry = require('../../src/components/app-body/notifications/notification-entry')
function renderNotificationEntry (entry) {
const element = React.createElement(NotificationEntry, { entry })
return ReactDOMServer.renderToStaticMarkup(element)
}
module.exports = { renderNotificationEntry }
@@ -1,6 +1,11 @@
/*
Display the Notifications page: replies to my posts, likes on my posts,
and new follows, newest first.
Each entry names its actor with the actor's Memo display name and avatar,
links both to the actor's profile, shows the full address as small plain
text, and offers a "View Post" link for like and reply notifications that
opens the referenced post's thread.
*/
// Global npm libraries
@@ -10,32 +15,24 @@ import { Container, Row, Col, Spinner, Button } from 'react-bootstrap'
// Local libraries
import MemoDb from '../../../services/memo-db'
import NotificationsPage from '../../../services/notifications-page'
import PostThreadModal from '../../post-thread-modal'
import NotificationEntry from './notification-entry'
import '../../../App.css'
const PAGE_SIZE = 50
function notificationText (n) {
if (n.type === 'reply') {
return `replied to your post: ${n.text || ''}`
}
if (n.type === 'like') {
return 'liked your post'
}
if (n.type === 'follow') {
return 'followed you'
}
return ''
}
function Notifications (props) {
const { appData } = props
const wallet = appData?.wallet
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [notifications, setNotifications] = useState([])
const [entries, setEntries] = useState([])
const [profiles, setProfiles] = useState({})
const [pagination, setPagination] = useState(null)
const [offset, setOffset] = useState(0)
const [threadTxid, setThreadTxid] = useState(null)
const [showThreadModal, setShowThreadModal] = useState(false)
useEffect(() => {
const loadNotifications = async () => {
@@ -47,11 +44,13 @@ function Notifications (props) {
const page = new NotificationsPage({ memoDb, wallet })
const data = await page.load({ limit: PAGE_SIZE, offset })
setNotifications(data.notifications || [])
setEntries(page.getEntries())
setProfiles(data.profiles || {})
setPagination(data.pagination || null)
} catch (err) {
setError(err.message || 'Failed to load notifications')
setNotifications([])
setEntries([])
setProfiles({})
setPagination(null)
}
@@ -61,6 +60,17 @@ function Notifications (props) {
loadNotifications()
}, [offset, wallet])
const openThread = (txid) => {
if (!txid) return
setThreadTxid(txid)
setShowThreadModal(true)
}
const closeThread = () => {
setShowThreadModal(false)
setThreadTxid(null)
}
const canGoBack = offset > 0
const canGoNext = pagination?.hasMore ?? false
@@ -80,10 +90,10 @@ function Notifications (props) {
<h1>Notifications</h1>
<p>Replies, likes, and follows involving you.</p>
{pagination && notifications.length > 0 && (
{pagination && entries.length > 0 && (
<span className='notifications-count'>
Showing {pagination.offset + 1}
{pagination.offset + notifications.length} of {pagination.total}
{pagination.offset + entries.length} of {pagination.total}
</span>
)}
</header>
@@ -104,19 +114,18 @@ function Notifications (props) {
</div>
)}
{!loading && !error && notifications.length === 0 && (
{!loading && !error && entries.length === 0 && (
<p className='notifications-empty'>You have no notifications.</p>
)}
{!loading && !error && notifications.length > 0 && (
{!loading && !error && entries.length > 0 && (
<div className='notifications-list'>
{notifications.map((n) => (
<div key={n.txid} className='notification-item' style={{ marginBottom: '1rem', padding: '0.75rem', border: '1px solid #dee2e6', borderRadius: '0.375rem' }}>
<p className='text-muted' style={{ fontFamily: 'monospace', marginBottom: '0.25rem' }}>
{n.addr}
</p>
<p style={{ marginBottom: 0 }}>{notificationText(n)}</p>
</div>
{entries.map((entry) => (
<NotificationEntry
key={entry.txid}
entry={entry}
onViewPost={openThread}
/>
))}
</div>
)}
@@ -142,6 +151,14 @@ function Notifications (props) {
)}
</Col>
</Row>
<PostThreadModal
show={showThreadModal}
txid={threadTxid}
onHide={closeThread}
wallet={wallet}
profiles={profiles}
/>
</Container>
)
}
@@ -0,0 +1,97 @@
/*
One notification entry on the Notifications page.
Names its actor with the actor's Memo display name and avatar, links both to
the actor's profile, and shows the full BCH address as small plain text. Like
and reply entries offer a "View Post" link that opens the referenced post's
thread; follow entries do not. When the actor has no display name the entry
shows the truncated address as the name, and when the actor has no avatar (or
the profile lookup failed) it falls back to an identicon.
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')
const JdenticonModule = require('@chris.troutner/react-jdenticon')
const { VIEW_POST_LABEL } = require('../../../services/notification-entry')
const Jdenticon = JdenticonModule.default || JdenticonModule
// The actor's avatar: the profile picture when present, an identicon otherwise.
function NotificationAvatar ({ addr, avatarUrl }) {
if (avatarUrl) {
return React.createElement('img', {
src: avatarUrl,
alt: 'Account avatar',
className: 'notification-entry-avatar',
width: 36,
height: 36
})
}
return React.createElement(
'div',
{ className: 'notification-entry-avatar notification-entry-identicon' },
React.createElement(Jdenticon, { value: addr, size: '36' })
)
}
function NotificationEntry ({ entry, onViewPost, onProfileClick }) {
if (!entry) return null
const handleProfileClick = (event) => {
if (!onProfileClick) return
event.preventDefault()
onProfileClick(entry.profilePath)
}
const handleViewPost = (event) => {
event.preventDefault()
if (onViewPost) onViewPost(entry.postTxid)
}
return React.createElement(
'div',
{ className: 'notification-item' },
React.createElement(
'a',
{
className: 'notification-entry-avatar-link',
href: entry.profilePath,
onClick: handleProfileClick,
'aria-label': `View ${entry.displayName}'s profile`
},
React.createElement(NotificationAvatar, { addr: entry.addr, avatarUrl: entry.avatarUrl })
),
React.createElement(
'div',
{ className: 'notification-entry-body' },
React.createElement(
'a',
{
className: 'notification-entry-name-link',
href: entry.profilePath,
onClick: handleProfileClick
},
entry.displayName
),
React.createElement('span', { className: 'notification-entry-address' }, entry.addr),
React.createElement('p', { className: 'notification-entry-text' }, entry.message || ''),
entry.showViewPost &&
React.createElement(
'a',
{
className: 'notification-entry-view-post',
href: '#',
onClick: handleViewPost
},
VIEW_POST_LABEL
)
)
)
}
module.exports = NotificationEntry
module.exports.NotificationAvatar = NotificationAvatar
@@ -0,0 +1,63 @@
/*
Build the Notification entry view model.
Each notification names its actor with the actor's Memo display name and
avatar, resolved client-side from the name and profile-picture records. The
React Notifications page renders one entry per notification from this model
so the naming, profile-link, and View Post behavior stay testable without a
DOM.
*/
const { truncateAddr } = require('../util')
const PROFILE_PATH_PREFIX = '/profile'
const VIEW_POST_LABEL = 'View Post'
const VIEW_POST_TYPES = ['like', 'reply']
// The profile path for an actor address, with the address URL-encoded.
function profilePath (addr) {
return `${PROFILE_PATH_PREFIX}/${encodeURIComponent(addr)}`
}
// The name to show for an actor: the Memo display name when present, and the
// truncated address otherwise.
function displayName (addr, profile) {
return profile?.name || truncateAddr(addr, 24)
}
// The action text for a notification entry.
function notificationMessage (notification) {
if (notification.type === 'reply') {
return `replied to your post: ${notification.text || ''}`
}
if (notification.type === 'like') {
return 'liked your post'
}
if (notification.type === 'follow') {
return 'followed you'
}
return ''
}
// Build the view model for one notification. `profile` is the actor's
// resolved profile ({ name, profilePicUrl }) or null when it is unavailable.
function buildNotificationEntry (notification, profile) {
return {
...notification,
displayName: displayName(notification.addr, profile),
avatarUrl: profile?.profilePicUrl || null,
profilePath: profilePath(notification.addr),
showViewPost: VIEW_POST_TYPES.includes(notification.type),
message: notificationMessage(notification)
}
}
module.exports = {
PROFILE_PATH_PREFIX,
VIEW_POST_LABEL,
VIEW_POST_TYPES,
profilePath,
displayName,
notificationMessage,
buildNotificationEntry
}
@@ -9,11 +9,14 @@
const NOTIFICATIONS_PATH = '/notifications'
const { buildNotificationEntry } = require('./notification-entry')
class NotificationsPage {
constructor (deps = {}) {
this.memoDb = deps.memoDb || null
this.wallet = deps.wallet || null
this.notifications = []
this.profiles = {}
this.pagination = null
this.empty = false
}
@@ -34,16 +37,50 @@ class NotificationsPage {
const data = await this.memoDb.getNotifications(myAddr, { limit, offset })
this.notifications = data.notifications || []
this.profiles = await this._loadProfiles(this.notifications)
this.pagination = data.pagination || null
this.empty = this.notifications.length === 0 && offset === 0
return {
notifications: this.notifications,
profiles: this.profiles,
pagination: this.pagination,
empty: this.empty
}
}
// Resolve the actor profile for every notification so the view can name and
// avatar each entry. A missing actor address is skipped.
async _loadProfiles (notifications) {
const addresses = [...new Set(notifications.map((n) => n.addr).filter(Boolean))]
const entries = await Promise.all(
addresses.map(async (addr) => [addr, await this._loadProfile(addr)])
)
return Object.fromEntries(entries)
}
// Resolve one actor's name and avatar URL, falling back to an empty profile
// when the lookup fails or the db client does not expose profile lookups.
async _loadProfile (addr) {
try {
const [nameRecord, picRecord] = await Promise.all([
this._loadProfileField('getName', addr),
this._loadProfileField('getProfilePic', addr)
])
return {
name: nameRecord?.name || null,
profilePicUrl: picRecord?.url || null
}
} catch (err) {
return { name: null, profilePicUrl: null }
}
}
async _loadProfileField (method, addr) {
if (typeof this.memoDb[method] !== 'function') return null
return this.memoDb[method](addr)
}
canLoadMore () {
return this.pagination?.hasMore ?? false
}
@@ -51,6 +88,25 @@ class NotificationsPage {
getNotification (txid) {
return this.notifications.find((n) => n.txid === txid) || null
}
// The view models for every loaded notification.
getEntries () {
return this.notifications.map((n) => this.getEntry(n.txid))
}
// The view model for a loaded notification by txid.
getEntry (txid) {
const notification = this.getNotification(txid)
if (!notification) return null
return buildNotificationEntry(notification, this.profiles[notification.addr])
}
// The view model for the notification whose actor is `addr`.
getEntryByAddr (addr) {
const notification = this.notifications.find((n) => n.addr === addr)
if (!notification) return null
return this.getEntry(notification.txid)
}
}
NotificationsPage.NOTIFICATIONS_PATH = NOTIFICATIONS_PATH
@@ -0,0 +1,80 @@
/*
Unit tests for the notification entry view model.
Each Notifications entry names its actor with the actor's Memo display name
and avatar, resolved client-side from the name and profile-picture records.
These tests pin the pure view-model behavior independent of the React shell.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const {
PROFILE_PATH_PREFIX,
VIEW_POST_LABEL,
profilePath,
displayName,
notificationMessage,
buildNotificationEntry
} = require('../../src/services/notification-entry')
const ALICE = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
const BOB = 'bitcoincash:qqq3728yw0y47sqn6l2na30mcw6zm78dzqre909m2r'
test('profilePath encodes the address for the profile route', () => {
assert.equal(profilePath(ALICE), `${PROFILE_PATH_PREFIX}/${encodeURIComponent(ALICE)}`)
assert.equal(profilePath(ALICE), '/profile/bitcoincash%3Aqr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy')
})
test('displayName prefers the profile display name', () => {
assert.equal(displayName(ALICE, { name: 'alice' }), 'alice')
})
test('displayName falls back to the truncated address without a name', () => {
assert.equal(displayName(ALICE, null), 'bitcoincas...4y0qverfuy')
assert.equal(displayName(BOB, { name: null }), 'bitcoincas...zqre909m2r')
assert.equal(displayName(BOB, {}), 'bitcoincas...zqre909m2r')
})
test('buildNotificationEntry carries the actor display name and profile path', () => {
const notification = { type: 'like', txid: 'a'.repeat(64), addr: ALICE, postTxid: 'b'.repeat(64) }
const entry = buildNotificationEntry(notification, { name: 'alice', profilePicUrl: 'https://example.com/alice.png' })
assert.equal(entry.addr, ALICE)
assert.equal(entry.displayName, 'alice')
assert.equal(entry.avatarUrl, 'https://example.com/alice.png')
assert.equal(entry.profilePath, `/profile/${encodeURIComponent(ALICE)}`)
assert.equal(entry.postTxid, 'b'.repeat(64))
})
test('buildNotificationEntry falls back to the truncated address without a profile', () => {
const notification = { type: 'follow', txid: 'c'.repeat(64), addr: BOB }
const entry = buildNotificationEntry(notification, null)
assert.equal(entry.displayName, 'bitcoincas...zqre909m2r')
assert.equal(entry.avatarUrl, null)
})
test('buildNotificationEntry offers a View Post link for like and reply notifications', () => {
for (const type of ['like', 'reply']) {
const entry = buildNotificationEntry({ type, txid: 'd'.repeat(64), addr: ALICE, postTxid: 'e'.repeat(64) }, null)
assert.equal(entry.showViewPost, true)
}
})
test('buildNotificationEntry does not offer a View Post link for follow notifications', () => {
const entry = buildNotificationEntry({ type: 'follow', txid: 'f'.repeat(64), addr: ALICE }, null)
assert.equal(entry.showViewPost, false)
})
test('exposes the View Post label', () => {
assert.equal(VIEW_POST_LABEL, 'View Post')
})
test('notificationMessage describes each notification type', () => {
assert.equal(notificationMessage({ type: 'reply', text: 'nice post' }), 'replied to your post: nice post')
assert.equal(notificationMessage({ type: 'like' }), 'liked your post')
assert.equal(notificationMessage({ type: 'follow' }), 'followed you')
assert.equal(notificationMessage({ type: 'unknown' }), '')
})
@@ -159,3 +159,80 @@ test('getNotification returns a loaded notification by txid', async () => {
test('exposes the notifications path', () => {
assert.equal(NotificationsPage.NOTIFICATIONS_PATH, '/notifications')
})
function makeProfileMemoDb (notifications, pagination, profiles = {}) {
return {
async getNotifications () {
return { notifications, pagination }
},
async getName (addr) {
const profile = profiles[addr]
if (profile?.throws) throw new Error('profile lookup failed')
return profile?.name ? { name: profile.name } : null
},
async getProfilePic (addr) {
const profile = profiles[addr]
if (profile?.throws) throw new Error('profile lookup failed')
return profile?.profilePicUrl ? { url: profile.profilePicUrl } : null
}
}
}
test('load resolves each actor profile and reports it', async () => {
const addr = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
const notifications = [{ type: 'like', txid: 'a'.repeat(64), addr }]
const profileDb = makeProfileMemoDb(notifications, { total: 1 }, {
[addr]: { name: 'alice', profilePicUrl: 'https://example.com/alice.png' }
})
const page = new NotificationsPage({ memoDb: profileDb, wallet: makeWallet() })
const result = await page.load()
assert.equal(result.profiles[addr].name, 'alice')
assert.equal(result.profiles[addr].profilePicUrl, 'https://example.com/alice.png')
})
test('load falls back to an empty profile when the lookup fails', async () => {
const addr = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
const notifications = [{ type: 'like', txid: 'a'.repeat(64), addr }]
const profileDb = makeProfileMemoDb(notifications, { total: 1 }, { [addr]: { throws: true } })
const page = new NotificationsPage({ memoDb: profileDb, wallet: makeWallet() })
const result = await page.load()
assert.deepEqual(result.profiles[addr], { name: null, profilePicUrl: null })
})
test('getEntries builds view models for every notification', async () => {
const addr = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
const notifications = [
{ type: 'reply', txid: 'a'.repeat(64), addr, postTxid: 'p'.repeat(64), text: 'nice post' },
{ type: 'follow', txid: 'b'.repeat(64), addr: 'bitcoincash:other' }
]
const profileDb = makeProfileMemoDb(notifications, { total: 2 }, {
[addr]: { name: 'alice', profilePicUrl: 'https://example.com/alice.png' }
})
const page = new NotificationsPage({ memoDb: profileDb, wallet: makeWallet() })
await page.load()
const entries = page.getEntries()
assert.equal(entries.length, 2)
assert.equal(entries[0].displayName, 'alice')
assert.equal(entries[0].avatarUrl, 'https://example.com/alice.png')
assert.equal(entries[0].showViewPost, true)
assert.equal(entries[1].displayName, 'bitcoincash:other')
assert.equal(entries[1].showViewPost, false)
})
test('getEntryByAddr returns the entry for an actor', async () => {
const addr = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
const notifications = [{ type: 'like', txid: 'a'.repeat(64), addr }]
const profileDb = makeProfileMemoDb(notifications, { total: 1 }, { [addr]: { name: 'alice' } })
const page = new NotificationsPage({ memoDb: profileDb, wallet: makeWallet() })
await page.load()
assert.equal(page.getEntryByAddr(addr).displayName, 'alice')
assert.equal(page.getEntryByAddr('bitcoincash:missing'), null)
})