diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js
index 8632260..6e95cec 100644
--- a/psf-memo-client/acceptance/lib/handlers.js
+++ b/psf-memo-client/acceptance/lib/handlers.js
@@ -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) {
diff --git a/psf-memo-client/acceptance/lib/render-notification-entry.js b/psf-memo-client/acceptance/lib/render-notification-entry.js
new file mode 100644
index 0000000..f4ddba0
--- /dev/null
+++ b/psf-memo-client/acceptance/lib/render-notification-entry.js
@@ -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 }
diff --git a/psf-memo-client/src/components/app-body/notifications/index.js b/psf-memo-client/src/components/app-body/notifications/index.js
index fae4b8d..5499960 100644
--- a/psf-memo-client/src/components/app-body/notifications/index.js
+++ b/psf-memo-client/src/components/app-body/notifications/index.js
@@ -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) {
Notifications
Replies, likes, and follows involving you.
- {pagination && notifications.length > 0 && (
+ {pagination && entries.length > 0 && (
Showing {pagination.offset + 1}–
- {pagination.offset + notifications.length} of {pagination.total}
+ {pagination.offset + entries.length} of {pagination.total}
)}
@@ -104,19 +114,18 @@ function Notifications (props) {
)}
- {!loading && !error && notifications.length === 0 && (
+ {!loading && !error && entries.length === 0 && (
You have no notifications.
)}
- {!loading && !error && notifications.length > 0 && (
+ {!loading && !error && entries.length > 0 && (
- {notifications.map((n) => (
-
-
- {n.addr}
-
-
{notificationText(n)}
-
+ {entries.map((entry) => (
+
))}
)}
@@ -142,6 +151,14 @@ function Notifications (props) {
)}
+
+
)
}
diff --git a/psf-memo-client/src/components/app-body/notifications/notification-entry.js b/psf-memo-client/src/components/app-body/notifications/notification-entry.js
new file mode 100644
index 0000000..e7d7552
--- /dev/null
+++ b/psf-memo-client/src/components/app-body/notifications/notification-entry.js
@@ -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
diff --git a/psf-memo-client/src/services/notification-entry.js b/psf-memo-client/src/services/notification-entry.js
new file mode 100644
index 0000000..e4c66bc
--- /dev/null
+++ b/psf-memo-client/src/services/notification-entry.js
@@ -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
+}
diff --git a/psf-memo-client/src/services/notifications-page.js b/psf-memo-client/src/services/notifications-page.js
index fd68282..24ab8d8 100644
--- a/psf-memo-client/src/services/notifications-page.js
+++ b/psf-memo-client/src/services/notifications-page.js
@@ -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
diff --git a/psf-memo-client/test/unit/notification-entry.test.js b/psf-memo-client/test/unit/notification-entry.test.js
new file mode 100644
index 0000000..c86cdf6
--- /dev/null
+++ b/psf-memo-client/test/unit/notification-entry.test.js
@@ -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' }), '')
+})
diff --git a/psf-memo-client/test/unit/notifications-page.test.js b/psf-memo-client/test/unit/notifications-page.test.js
index b52029c..607a2b8 100644
--- a/psf-memo-client/test/unit/notifications-page.test.js
+++ b/psf-memo-client/test/unit/notifications-page.test.js
@@ -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)
+})