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) +})