mirror of
https://github.com/Permissionless-Software-Foundation/psf-memo.git
synced 2026-09-22 17:22:01 -07:00
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:
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user