Implement Notifications read-only feature

Add psf-memo-db notifications query, use case, route, and tests.
Add psf-memo-client NotificationsPage service, React page, nav link,
route, and tests. Wire acceptance handlers for notifications.

By coder.
This commit is contained in:
Chris Troutner
2026-09-02 12:53:02 -07:00
parent 297abf6389
commit 8ee07fc35f
17 changed files with 1218 additions and 2 deletions
+237 -2
View File
@@ -41,6 +41,7 @@ const ThreadPage = require('../../src/services/thread-page')
const TopicDiscoveryPage = require('../../src/services/topic-discovery-page')
const TopicFeedPage = require('../../src/services/topic-feed-page')
const SearchPage = require('../../src/services/search-page')
const NotificationsPage = require('../../src/services/notifications-page')
const MemoTopicFollow = require('../../src/services/memo-topic-follow')
const MemoTopicPost = require('../../src/services/memo-topic-post')
const TopicPostPage = require('../../src/services/topic-post-page')
@@ -190,6 +191,9 @@ function makeMemoDb () {
const followState = {}
const muteState = {}
const replyTxids = new Set()
const replies = []
const likes = []
const followers = new Map()
const topics = []
const topicPosts = {}
const topicCounts = new Map()
@@ -210,7 +214,20 @@ function makeMemoDb () {
},
addReply (reply) {
replyTxids.add(reply.txid)
posts.push(reply)
replies.push({
txid: reply.txid,
parentTxid: reply.parentTxid,
text: reply.text,
addr: reply.addr || 'bitcoincash:reply-author',
blockHeight: reply.blockHeight ?? 100
})
posts.push({
txid: reply.txid,
addr: reply.addr || 'bitcoincash:reply-author',
text: reply.text,
blockHeight: reply.blockHeight ?? 100,
seen: reply.seen ?? 0
})
},
addSearchPost (post) {
searchPosts.push(post)
@@ -218,6 +235,24 @@ function makeMemoDb () {
addSearchProfile (profile) {
searchProfiles.push(profile)
},
addLike (like) {
likes.push({
txid: like.txid,
postTxid: like.postTxid,
addr: like.addr,
blockHeight: like.blockHeight ?? 100
})
},
addFollower (followerAddr, followeeAddr, opts = {}) {
const list = followers.get(followeeAddr) || []
list.push({
followerAddr,
followeeAddr,
txid: opts.txid || require('crypto').createHash('sha256').update(`${followerAddr}:${followeeAddr}`).digest('hex'),
blockHeight: opts.blockHeight ?? 100
})
followers.set(followeeAddr, list)
},
addTopic (room, postCount) {
topicCounts.set(room, postCount)
topicPosts[room] = []
@@ -309,6 +344,52 @@ function makeMemoDb () {
const page = all.slice(offset, offset + limit)
return { posts: page, pagination: { total: all.length, limit, offset, hasMore: offset + page.length < all.length } }
},
async getNotifications (addr, { limit = 100, offset = 0 } = {}) {
const notifications = []
for (const reply of replies) {
const parent = posts.find((p) => p.txid === reply.parentTxid)
if (!parent || parent.addr !== addr) continue
if (reply.addr === addr) continue
notifications.push({
type: 'reply',
txid: reply.txid,
addr: reply.addr,
postTxid: reply.parentTxid,
text: reply.text,
blockHeight: reply.blockHeight ?? parent.blockHeight ?? 0
})
}
for (const like of likes) {
const post = posts.find((p) => p.txid === like.postTxid)
if (!post || post.addr !== addr) continue
if (like.addr === addr) continue
notifications.push({
type: 'like',
txid: like.txid,
addr: like.addr,
postTxid: like.postTxid,
blockHeight: like.blockHeight ?? post.blockHeight ?? 0
})
}
for (const follow of (followers.get(addr) || [])) {
if (follow.followerAddr === addr) continue
notifications.push({
type: 'follow',
txid: follow.txid,
addr: follow.followerAddr,
blockHeight: follow.blockHeight ?? 0
})
}
notifications.sort((a, b) => (b.blockHeight ?? 0) - (a.blockHeight ?? 0))
const total = notifications.length
const page = notifications.slice(offset, offset + limit)
return { notifications: page, pagination: { total, limit, offset, hasMore: offset + page.length < total } }
},
async getFollowingFeed (addr, { limit = 100, offset = 0 } = {}) {
const followees = new Set()
for (const [key, following] of Object.entries(followState)) {
@@ -362,6 +443,7 @@ function createWorld () {
// Read-only page controllers backed by the fake psf-memo-db API.
world.recentFeedPage = new RecentFeedPage({ memoDb })
world.followingFeedPage = new FollowingFeedPage({ memoDb, wallet })
world.notificationsPage = new NotificationsPage({ memoDb, wallet })
world.profilePage = new ProfilePage({ memoDb })
world.threadPage = new ThreadPage({ memoDb })
world.topicDiscoveryPage = new TopicDiscoveryPage({
@@ -2276,7 +2358,7 @@ const handlers = [
},
{
name: 'API serves reply with txid parent and text',
pattern: /^the psf-memo-db API serves a reply with txid (.+) to the post with txid (.+) with text (.+)$/,
pattern: /^the psf-memo-db API serves a reply with txid ([0-9a-fA-F]{64}) to the post with txid ([0-9a-fA-F]{64}) with text (.+)$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const parentTxid = resolveParam(m[2], example)
@@ -2392,6 +2474,159 @@ const handlers = [
throw new Error('Expected following feed to show the not-following-anyone message.')
}
}
},
{
name: 'API serves reply to my post by address with text',
pattern: /^the psf-memo-db API serves a reply with txid (.+) to the post with txid (.+) by the address (.+) with text (.+?)(?: at block height (\d+))?$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const parentTxid = resolveParam(m[2], example)
const addr = resolveParam(m[3], example)
const text = resolveText(m[4], example)
const blockHeight = m[5] ? parseInt(m[5], 10) : 100
world.memoDb.addReply({ txid, parentTxid, text, addr, blockHeight })
}
},
{
name: 'API serves reply to my post by me with text',
pattern: /^the psf-memo-db API serves a reply with txid (.+) to the post with txid (.+) by my wallet address with text (.+?)(?: at block height (\d+))?$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const parentTxid = resolveParam(m[2], example)
const text = resolveText(m[3], example)
const blockHeight = m[4] ? parseInt(m[4], 10) : 100
const myAddr = world.wallet.walletInfo.cashAddress
world.memoDb.addReply({ txid, parentTxid, text, addr: myAddr, blockHeight })
}
},
{
name: 'API serves like on my post by address',
pattern: /^the psf-memo-db API serves a like with txid (.+) on the post with txid (.+) by the address (.+?)(?: at block height (\d+))?$/,
run (m, example, world) {
const txid = resolveParam(m[1], example)
const postTxid = resolveParam(m[2], example)
const addr = resolveParam(m[3], example)
const blockHeight = m[4] ? parseInt(m[4], 10) : 100
world.memoDb.addLike({ txid, postTxid, addr, blockHeight })
}
},
{
name: 'API records address follows me',
pattern: /^the psf-memo-db API records that the address (.+) follows my wallet address$/,
run (m, example, world) {
const followerAddr = resolveParam(m[1], example)
const myAddr = world.wallet.walletInfo.cashAddress
world.memoDb.addFollower(followerAddr, myAddr)
}
},
{
name: 'open notifications page',
pattern: /^I open the Notifications page$/,
async run (m, example, world) {
await world.notificationsPage.load()
world.currentPath = NotificationsPage.NOTIFICATIONS_PATH
}
},
{
name: 'open notifications page with page size',
pattern: /^I open the Notifications page with page size (\d+)$/,
async run (m, example, world) {
const limit = parseInt(m[1], 10)
await world.notificationsPage.load({ limit })
world.currentPath = NotificationsPage.NOTIFICATIONS_PATH
}
},
{
name: 'notifications include reply notification',
pattern: /^the notifications include a reply notification from the address (.+) with text (.+)$/,
run (m, example, world) {
const expectedAddr = resolveParam(m[1], example)
const expectedText = resolveText(m[2], example)
const found = world.notificationsPage.notifications.find((n) =>
n.type === 'reply' && n.addr === expectedAddr && n.text === expectedText
)
if (!found) {
throw new Error(`Notifications do not include a reply from ${expectedAddr} with text "${expectedText}".`)
}
}
},
{
name: 'notifications include like notification',
pattern: /^the notifications include a like notification from the address (.+)$/,
run (m, example, world) {
const expectedAddr = resolveParam(m[1], example)
const found = world.notificationsPage.notifications.find((n) =>
n.type === 'like' && n.addr === expectedAddr
)
if (!found) {
throw new Error(`Notifications do not include a like from ${expectedAddr}.`)
}
}
},
{
name: 'notifications include follow notification',
pattern: /^the notifications include a follow notification from the address (.+)$/,
run (m, example, world) {
const expectedAddr = resolveParam(m[1], example)
const found = world.notificationsPage.notifications.find((n) =>
n.type === 'follow' && n.addr === expectedAddr
)
if (!found) {
throw new Error(`Notifications do not include a follow from ${expectedAddr}.`)
}
}
},
{
name: 'notifications show like before reply',
pattern: /^the notifications show the like notification before the reply notification$/,
run (m, example, world) {
const notifications = world.notificationsPage.notifications
const likeIndex = notifications.findIndex((n) => n.type === 'like')
const replyIndex = notifications.findIndex((n) => n.type === 'reply')
if (likeIndex === -1) throw new Error('Notifications do not include a like notification.')
if (replyIndex === -1) throw new Error('Notifications do not include a reply notification.')
if (likeIndex >= replyIndex) {
throw new Error('Expected like notification to appear before reply notification.')
}
}
},
{
name: 'notifications show N notifications',
pattern: /^the notifications show (\d+) notification$/,
run (m, example, world) {
const expected = parseInt(m[1], 10)
const actual = world.notificationsPage.notifications.length
if (actual !== expected) {
throw new Error(`Expected ${expected} notifications, got ${actual}.`)
}
}
},
{
name: 'notifications can load more',
pattern: /^the notifications can load more$/,
run (m, example, world) {
if (!world.notificationsPage.canLoadMore()) {
throw new Error('Expected notifications to have more pages, but pagination says there are none.')
}
}
},
{
name: 'notifications include no notifications',
pattern: /^the notifications include no notifications$/,
run (m, example, world) {
if (world.notificationsPage.notifications.length !== 0) {
throw new Error(`Expected no notifications, got ${world.notificationsPage.notifications.length}.`)
}
}
},
{
name: 'notifications show no notifications message',
pattern: /^the notifications show a message that I have no notifications$/,
run (m, example, world) {
if (!world.notificationsPage.empty) {
throw new Error('Expected notifications page to show the no-notifications message.')
}
}
}
]
@@ -35,6 +35,7 @@ import Topics from './topics'
import TopicFeed from './topic-feed'
import Search from './search'
import FollowingFeed from './following-feed'
import Notifications from './notifications'
function AppBody (props) {
// Dependency injection through props
@@ -56,6 +57,7 @@ function AppBody (props) {
<Route path='/topics/:room' element={<TopicFeed appData={appData} />} />
<Route path='/search' element={<Search />} />
<Route path='/posts/following' element={<FollowingFeed appData={appData} />} />
<Route path='/notifications' element={<Notifications appData={appData} />} />
<Route path='/memo/set-name' element={<SetName appData={appData} />} />
<Route path='/memo/set-bio' element={<SetBio appData={appData} />} />
<Route path='/memo/set-avatar-url' element={<SetAvatarUrl appData={appData} />} />
@@ -0,0 +1,149 @@
/*
Display the Notifications page: replies to my posts, likes on my posts,
and new follows, newest first.
*/
// Global npm libraries
import React, { useState, useEffect } from 'react'
import { Container, Row, Col, Spinner, Button } from 'react-bootstrap'
// Local libraries
import MemoDb from '../../../services/memo-db'
import NotificationsPage from '../../../services/notifications-page'
import '../../../App.css'
const PAGE_SIZE = 100
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 [pagination, setPagination] = useState(null)
const [offset, setOffset] = useState(0)
useEffect(() => {
const loadNotifications = async () => {
setLoading(true)
setError(null)
try {
const memoDb = new MemoDb()
const page = new NotificationsPage({ memoDb, wallet })
const data = await page.load({ limit: PAGE_SIZE, offset })
setNotifications(data.notifications || [])
setPagination(data.pagination || null)
} catch (err) {
setError(err.message || 'Failed to load notifications')
setNotifications([])
setPagination(null)
}
setLoading(false)
}
loadNotifications()
}, [offset, wallet])
const canGoBack = offset > 0
const canGoNext = pagination?.hasMore ?? false
const handlePrevious = () => {
setOffset((prev) => Math.max(0, prev - PAGE_SIZE))
}
const handleNext = () => {
setOffset((prev) => prev + PAGE_SIZE)
}
return (
<Container className='notifications-page'>
<Row className='justify-content-center'>
<Col lg={8} md={10} xs={12}>
<header className='notifications-heading'>
<h1>Notifications</h1>
<p>Replies, likes, and follows involving you.</p>
{pagination && notifications.length > 0 && (
<span className='notifications-count'>
Showing {pagination.offset + 1}
{pagination.offset + notifications.length} of {pagination.total}
</span>
)}
</header>
{error && (
<p className='notifications-error'>
{error}
</p>
)}
{loading && (
<div className='text-center my-5'>
<Spinner animation='border' role='status'>
<span className='visually-hidden'>
Loading...
</span>
</Spinner>
</div>
)}
{!loading && !error && notifications.length === 0 && (
<p className='notifications-empty'>You have no notifications.</p>
)}
{!loading && !error && notifications.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>
))}
</div>
)}
{!loading && !error && (pagination || offset > 0) && (
<div className='notifications-pagination'>
<Button
variant='outline-dark'
onClick={handlePrevious}
disabled={!canGoBack}
>
Previous
</Button>
<Button
variant='outline-dark'
onClick={handleNext}
disabled={!canGoNext}
>
Next
</Button>
</div>
)}
</Col>
</Row>
</Container>
)
}
export default Notifications
@@ -94,6 +94,14 @@ function NavMenu (props) {
Following
</NavLink>
<NavLink
className={currentPath === '/notifications' ? 'nav-link-active' : 'nav-link-inactive'}
to='/notifications'
onClick={handleClickEvent}
>
Notifications
</NavLink>
<NavLink
className={currentPath === '/posts/new' ? 'nav-link-active' : 'nav-link-inactive'}
to='/posts/new'
+4
View File
@@ -157,6 +157,10 @@ class MemoDb {
async getFollowingFeed (addr, opts = {}) {
return this.getPage(`/posts/following/${encodeURIComponent(addr)}`, 'getFollowingFeed', opts)
}
async getNotifications (addr, opts = {}) {
return this.getPage(`/posts/notifications/${encodeURIComponent(addr)}`, 'getNotifications', opts)
}
}
export default MemoDb
@@ -0,0 +1,58 @@
/*
Notifications Page behavior: load and display replies, likes, and follows
that involve the viewer.
This is the testable controller behind the React Notifications page. It
wraps the MemoDb client, identifies the viewer from the injected wallet, and
exposes the loaded notifications so the view can render them.
*/
const NOTIFICATIONS_PATH = '/notifications'
class NotificationsPage {
constructor (deps = {}) {
this.memoDb = deps.memoDb || null
this.wallet = deps.wallet || null
this.notifications = []
this.pagination = null
this.empty = false
}
getMyAddress () {
return this.wallet?.walletInfo?.cashAddress || null
}
async load ({ limit = 100, offset = 0 } = {}) {
if (!this.memoDb) {
throw new Error('Notifications page requires a memo db client.')
}
const myAddr = this.getMyAddress()
if (!myAddr) {
throw new Error('Notifications page requires an authenticated wallet.')
}
const data = await this.memoDb.getNotifications(myAddr, { limit, offset })
this.notifications = data.notifications || []
this.pagination = data.pagination || null
this.empty = this.notifications.length === 0 && offset === 0
return {
notifications: this.notifications,
pagination: this.pagination,
empty: this.empty
}
}
canLoadMore () {
return this.pagination?.hasMore ?? false
}
getNotification (txid) {
return this.notifications.find((n) => n.txid === txid) || null
}
}
NotificationsPage.NOTIFICATIONS_PATH = NOTIFICATIONS_PATH
module.exports = NotificationsPage
@@ -0,0 +1,145 @@
/*
Unit tests for the notifications page controller.
*/
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const NotificationsPage = require('../../src/services/notifications-page')
const MY_ADDRESS = 'bitcoincash:qqlrzp23w08434twtmvr4fxw672whkjy0py26r63g3d'
function makeWallet () {
return {
walletInfo: { cashAddress: MY_ADDRESS }
}
}
function makeMemoDb (notifications, pagination) {
return {
async getNotifications (addr, { limit, offset }) {
return { notifications, pagination }
}
}
}
test('load returns notifications', async () => {
const notifications = [
{ type: 'reply', txid: 'a'.repeat(64), addr: 'bitcoincash:other', text: 'hi' },
{ type: 'like', txid: 'b'.repeat(64), addr: 'bitcoincash:other2' }
]
const page = new NotificationsPage({
memoDb: makeMemoDb(notifications, { total: 2 }),
wallet: makeWallet()
})
const result = await page.load()
assert.deepEqual(result.notifications, notifications)
assert.equal(result.pagination.total, 2)
assert.equal(result.empty, false)
})
test('load marks empty notifications at offset zero', async () => {
const page = new NotificationsPage({
memoDb: makeMemoDb([], { total: 0 }),
wallet: makeWallet()
})
const result = await page.load()
assert.deepEqual(result.notifications, [])
assert.equal(result.empty, true)
})
test('load does not mark empty paginated page as empty', async () => {
const page = new NotificationsPage({
memoDb: makeMemoDb([], { total: 2 }),
wallet: makeWallet()
})
const result = await page.load({ offset: 100 })
assert.equal(result.empty, false)
})
test('load forwards limit and offset to the memo db client', async () => {
const calls = []
const memoDb = {
async getNotifications (addr, params) {
calls.push({ addr, params })
return { notifications: [], pagination: {} }
}
}
const page = new NotificationsPage({ memoDb, wallet: makeWallet() })
await page.load({ limit: 10, offset: 20 })
assert.deepEqual(calls, [{ addr: MY_ADDRESS, params: { limit: 10, offset: 20 } }])
})
test('load defaults limit to 100 and offset to 0', async () => {
const calls = []
const memoDb = {
async getNotifications (addr, params) {
calls.push({ addr, params })
return { notifications: [], pagination: {} }
}
}
const page = new NotificationsPage({ memoDb, wallet: makeWallet() })
await page.load()
assert.deepEqual(calls, [{ addr: MY_ADDRESS, params: { limit: 100, offset: 0 } }])
})
test('load throws when no memo db client is provided', async () => {
const page = new NotificationsPage({ wallet: makeWallet() })
await assert.rejects(
() => page.load(),
/requires a memo db client/
)
})
test('load throws when no wallet is provided', async () => {
const page = new NotificationsPage({ memoDb: makeMemoDb([], {}) })
await assert.rejects(
() => page.load(),
/requires an authenticated wallet/
)
})
test('canLoadMore reflects pagination.hasMore', async () => {
const pageMore = new NotificationsPage({
memoDb: makeMemoDb([], { hasMore: true }),
wallet: makeWallet()
})
await pageMore.load()
assert.equal(pageMore.canLoadMore(), true)
const pageDone = new NotificationsPage({
memoDb: makeMemoDb([], { hasMore: false }),
wallet: makeWallet()
})
await pageDone.load()
assert.equal(pageDone.canLoadMore(), false)
})
test('getNotification returns a loaded notification by txid', async () => {
const notifications = [{ type: 'follow', txid: 'a'.repeat(64), addr: 'bitcoincash:other' }]
const page = new NotificationsPage({
memoDb: makeMemoDb(notifications, {}),
wallet: makeWallet()
})
await page.load()
assert.equal(page.getNotification('a'.repeat(64)).type, 'follow')
})
test('exposes the notifications path', () => {
assert.equal(NotificationsPage.NOTIFICATIONS_PATH, '/notifications')
})
+9
View File
@@ -11,6 +11,7 @@ import MuteQuery from './mute-query.js'
import TopicQuery from './topic-query.js'
import PollQuery from './poll-query.js'
import SearchQuery from './search-query.js'
import NotificationsQuery from './notifications-query.js'
class Adapters {
constructor () {
@@ -57,6 +58,14 @@ class Adapters {
namesDb: level.namesDb,
profilesDb: level.profilesDb
})
this.notificationsQuery = new NotificationsQuery({
postsDb: level.postsDb,
postParentsDb: level.postParentsDb,
postChildrenDb: level.postChildrenDb,
likesDb: level.likesDb,
postLikesDb: level.postLikesDb,
followsDb: level.followsDb
})
return true
}
@@ -0,0 +1,166 @@
/*
Adapter for aggregating the viewer's notifications.
Notifications are read-only: the DB collects replies to the viewer's posts,
likes on the viewer's posts, and new follows of the viewer, then returns them
sorted newest-first with limit/offset pagination.
*/
import BCHJS from '@psf/bch-js'
class NotificationsQuery {
constructor (localConfig = {}) {
const {
postsDb,
postParentsDb,
postChildrenDb,
likesDb,
postLikesDb,
followsDb,
bchjs = new BCHJS({ restURL: process.env.RESTURL || 'https://api.fullstack.cash/v5/' })
} = localConfig
if (!postsDb) {
throw new Error('postsDb required when instantiating NotificationsQuery adapter.')
}
if (!postParentsDb) {
throw new Error('postParentsDb required when instantiating NotificationsQuery adapter.')
}
if (!postChildrenDb) {
throw new Error('postChildrenDb required when instantiating NotificationsQuery adapter.')
}
if (!likesDb) {
throw new Error('likesDb required when instantiating NotificationsQuery adapter.')
}
if (!postLikesDb) {
throw new Error('postLikesDb required when instantiating NotificationsQuery adapter.')
}
if (!followsDb) {
throw new Error('followsDb required when instantiating NotificationsQuery adapter.')
}
this.postsDb = postsDb
this.postParentsDb = postParentsDb
this.postChildrenDb = postChildrenDb
this.likesDb = likesDb
this.postLikesDb = postLikesDb
this.followsDb = followsDb
this.bchjs = bchjs
this.listNotifications = this.listNotifications.bind(this)
this._getPostOrNull = this._getPostOrNull.bind(this)
this._collectFollowNotifications = this._collectFollowNotifications.bind(this)
this._collectLikeNotifications = this._collectLikeNotifications.bind(this)
this._collectReplyNotifications = this._collectReplyNotifications.bind(this)
this._sortNotifications = this._sortNotifications.bind(this)
}
async _getPostOrNull (txid) {
try {
return await this.postsDb.get(txid)
} catch (err) {
if (err.notFound || err.code === 'LEVEL_NOT_FOUND') return null
throw err
}
}
// Collect active follows where this address is the followee.
async _collectFollowNotifications (addr) {
const myHash160 = this.bchjs.Address.toHash160(addr)
const notifications = []
for await (const [key, record] of this.followsDb.iterator()) {
if (record.unfollow === true) continue
if (record.followeePkHash !== myHash160) continue
const followerAddr = record.followerAddr || key.split(':')[0]
if (followerAddr === addr) continue
notifications.push({
type: 'follow',
txid: record.txid,
addr: followerAddr,
blockHeight: record.blockHeight ?? 0,
seen: record.seen ?? 0
})
}
return notifications
}
// Collect likes on posts authored by this address, excluding self-likes.
async _collectLikeNotifications (addr) {
const notifications = []
for await (const [likeTxid, like] of this.likesDb.iterator()) {
if (!like || like.addr === addr) continue
const post = await this._getPostOrNull(like.postTxid)
if (!post || post.addr !== addr) continue
notifications.push({
type: 'like',
txid: likeTxid,
addr: like.addr,
postTxid: like.postTxid,
blockHeight: like.blockHeight ?? 0,
seen: like.seen ?? 0
})
}
return notifications
}
// Collect replies to posts authored by this address, excluding own replies.
async _collectReplyNotifications (addr) {
const notifications = []
for await (const [, child] of this.postChildrenDb.iterator()) {
const parentTxid = child?.parentTxid
const childTxid = child?.childTxid
if (!parentTxid || !childTxid) continue
const parent = await this._getPostOrNull(parentTxid)
if (!parent || parent.addr !== addr) continue
const childPost = await this._getPostOrNull(childTxid)
if (!childPost || childPost.addr === addr) continue
notifications.push({
type: 'reply',
txid: childTxid,
addr: childPost.addr,
postTxid: parentTxid,
text: childPost.text,
blockHeight: child.blockHeight ?? childPost.blockHeight ?? 0,
seen: childPost.seen ?? 0
})
}
return notifications
}
_sortNotifications (notifications) {
return notifications.sort((a, b) => {
if (b.blockHeight !== a.blockHeight) return b.blockHeight - a.blockHeight
return (b.seen ?? 0) - (a.seen ?? 0)
})
}
// Return paginated notifications for addr, sorted newest-first.
async listNotifications (addr, { limit, offset } = {}) {
const [follows, likes, replies] = await Promise.all([
this._collectFollowNotifications(addr),
this._collectLikeNotifications(addr),
this._collectReplyNotifications(addr)
])
const all = this._sortNotifications(follows.concat(likes).concat(replies))
const total = all.length
const page = all.slice(offset, offset + limit)
return { notifications: page, total }
}
}
export default NotificationsQuery
@@ -18,6 +18,7 @@ class PostsRESTControllerLib {
this.getRecentPosts = this.getRecentPosts.bind(this)
this.getPostsByAddr = this.getPostsByAddr.bind(this)
this.getFollowingFeed = this.getFollowingFeed.bind(this)
this.getNotifications = this.getNotifications.bind(this)
this.getPostThread = this.getPostThread.bind(this)
this.runUseCase = this.runUseCase.bind(this)
this.listPostsForAddr = this.listPostsForAddr.bind(this)
@@ -145,6 +146,34 @@ class PostsRESTControllerLib {
await this.listPostsForAddr(ctx, this.useCases.listFollowingFeed)
}
/**
* @api {get} /posts/notifications/:addr List notifications for an address
* @apiPermission public
* @apiName GetNotifications
* @apiGroup REST Posts
*
* @apiDescription Returns replies to the viewer's posts, likes on the viewer's posts, and new follows of the viewer, sorted by block height (newest first).
*
* @apiParam {String} addr Viewer cash address
* @apiQuery {Number} [limit=100] Page size (max 100)
* @apiQuery {Number} [offset=0] Number of notifications to skip after sorting
*
* @apiExample Example usage:
* curl -X GET "localhost:5021/posts/notifications/bitcoincash:q...?limit=50&offset=0"
*
* @apiSuccess {Object[]} notifications Array of notification objects
* @apiSuccess {String} notifications.type One of reply, like, follow
* @apiSuccess {String} notifications.txid Action transaction id
* @apiSuccess {String} notifications.addr Actor cash address
* @apiSuccess {String} [notifications.postTxid] Liked/replied post txid
* @apiSuccess {String} [notifications.text] Reply text
* @apiSuccess {Number} notifications.blockHeight Block height when indexed
* @apiSuccess {Object} pagination Pagination metadata
*/
async getNotifications (ctx) {
await this.listPostsForAddr(ctx, this.useCases.listNotifications)
}
async getPostThread (ctx) {
const { txid } = ctx.params
await this.runUseCase(ctx, () => this.useCases.getPostThread.execute({
@@ -27,6 +27,7 @@ class PostsRouter {
this.router.get('/recent', this.postsRESTController.getRecentPosts)
this.router.get('/by/:addr', this.postsRESTController.getPostsByAddr)
this.router.get('/following/:addr', this.postsRESTController.getFollowingFeed)
this.router.get('/notifications/:addr', this.postsRESTController.getNotifications)
this.router.get('/:txid/thread', this.postsRESTController.getPostThread)
app.use(this.router.routes())
app.use(this.router.allowedMethods())
+6
View File
@@ -20,6 +20,7 @@ import GetPoll from './get-poll.js'
import GetPollOptions from './get-poll-options.js'
import GetPollVotes from './get-poll-votes.js'
import SearchAll from './search-all.js'
import ListNotifications from './list-notifications.js'
class UseCases {
constructor (localConfig = {}) {
@@ -49,6 +50,7 @@ class UseCases {
this.getPollOptions = null
this.getPollVotes = null
this.searchAll = null
this.listNotifications = null
}
async start () {
@@ -124,6 +126,10 @@ class UseCases {
adapters: this.adapters
})
this.listNotifications = new ListNotifications({
adapters: this.adapters
})
console.log('Use cases initialized.')
return true
@@ -0,0 +1,35 @@
/*
Use case: list notifications for a viewer, newest first.
Aggregates replies to the viewer's posts, likes on the viewer's posts, and
follows of the viewer, then paginates the combined result.
*/
import { parseLimit, parseOffset, parseRequiredString } from './lib/pagination.js'
import { ListUseCase } from './lib/use-case.js'
class ListNotifications extends ListUseCase {
constructor (localConfig = {}) {
super(localConfig, { useCaseName: 'ListNotifications', adapterName: 'notificationsQuery' })
}
async execute (inObj = {}) {
const addr = parseRequiredString(inObj.addr, 'addr')
const limit = parseLimit(inObj.limit)
const offset = parseOffset(inObj.offset)
const { notifications, total } = await this.adapters.notificationsQuery.listNotifications(addr, { limit, offset })
return {
notifications,
pagination: {
limit,
offset,
total,
hasMore: offset + notifications.length < total
}
}
}
}
export default ListNotifications
@@ -0,0 +1,251 @@
import { assert } from 'chai'
import sinon from 'sinon'
import NotificationsQuery from '../../../src/adapters/notifications-query.js'
describe('#NotificationsQuery', () => {
let sandbox
let postsDb
let postChildrenDb
let likesDb
let followsDb
let bchjs
let uut
const MY_ADDR = 'bitcoincash:qqlrzp23w08434twtmvr4fxw672whkjy0py26r63g3d'
const THEIR_ADDR = 'bitcoincash:qr95sy3j9xwd2ap32xkykttr4cvcu7as4y0qverfuy'
const MY_HASH160 = 'myhash160'
const THEIR_HASH160 = 'theirhash160'
function makeIterator (items) {
return (async function * () {
for (const item of items) yield item
}())
}
beforeEach(() => {
sandbox = sinon.createSandbox()
postsDb = { get: sandbox.stub() }
postChildrenDb = { iterator: sandbox.stub() }
likesDb = { iterator: sandbox.stub() }
followsDb = { iterator: sandbox.stub() }
bchjs = {
Address: {
toHash160: sandbox.stub()
}
}
bchjs.Address.toHash160.withArgs(MY_ADDR).returns(MY_HASH160)
bchjs.Address.toHash160.withArgs(THEIR_ADDR).returns(THEIR_HASH160)
uut = new NotificationsQuery({
postsDb,
postParentsDb: {},
postChildrenDb,
likesDb,
postLikesDb: {},
followsDb,
bchjs
})
})
afterEach(() => sandbox.restore())
it('should throw when postsDb is missing', () => {
try {
// eslint-disable-next-line no-new
new NotificationsQuery({ postChildrenDb, likesDb, followsDb, bchjs })
assert.fail('Expected error')
} catch (err) {
assert.include(err.message, 'postsDb required')
}
})
it('should throw when postChildrenDb is missing', () => {
try {
// eslint-disable-next-line no-new
new NotificationsQuery({ postsDb, postParentsDb: {}, likesDb, postLikesDb: {}, followsDb, bchjs })
assert.fail('Expected error')
} catch (err) {
assert.include(err.message, 'postChildrenDb required')
}
})
it('should throw when likesDb is missing', () => {
try {
// eslint-disable-next-line no-new
new NotificationsQuery({ postsDb, postParentsDb: {}, postChildrenDb, postLikesDb: {}, followsDb, bchjs })
assert.fail('Expected error')
} catch (err) {
assert.include(err.message, 'likesDb required')
}
})
it('should throw when followsDb is missing', () => {
try {
// eslint-disable-next-line no-new
new NotificationsQuery({ postsDb, postParentsDb: {}, postChildrenDb, likesDb, postLikesDb: {}, bchjs })
assert.fail('Expected error')
} catch (err) {
assert.include(err.message, 'followsDb required')
}
})
it('should include a reply to my post', async () => {
const myPostTxid = 'a'.repeat(64)
const replyTxid = 'b'.repeat(64)
postChildrenDb.iterator.returns(makeIterator([
[`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 200 }]
]))
likesDb.iterator.returns(makeIterator([]))
followsDb.iterator.returns(makeIterator([]))
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
postsDb.get.withArgs(replyTxid).resolves({ addr: THEIR_ADDR, text: 'nice post', blockHeight: 200 })
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
assert.equal(result.total, 1)
assert.equal(result.notifications.length, 1)
const n = result.notifications[0]
assert.equal(n.type, 'reply')
assert.equal(n.txid, replyTxid)
assert.equal(n.addr, THEIR_ADDR)
assert.equal(n.postTxid, myPostTxid)
assert.equal(n.text, 'nice post')
})
it('should include a like on my post', async () => {
const myPostTxid = 'a'.repeat(64)
const likeTxid = 'b'.repeat(64)
postChildrenDb.iterator.returns(makeIterator([]))
followsDb.iterator.returns(makeIterator([]))
likesDb.iterator.returns(makeIterator([
[likeTxid, { addr: THEIR_ADDR, postTxid: myPostTxid, blockHeight: 300 }]
]))
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
assert.equal(result.total, 1)
const n = result.notifications[0]
assert.equal(n.type, 'like')
assert.equal(n.txid, likeTxid)
assert.equal(n.addr, THEIR_ADDR)
assert.equal(n.postTxid, myPostTxid)
})
it('should include a follow of me', async () => {
postChildrenDb.iterator.returns(makeIterator([]))
likesDb.iterator.returns(makeIterator([]))
followsDb.iterator.returns(makeIterator([
[`${THEIR_ADDR}:${MY_HASH160}`, { followerAddr: THEIR_ADDR, followeePkHash: MY_HASH160, unfollow: false, txid: 'c'.repeat(64), blockHeight: 150 }]
]))
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
assert.equal(result.total, 1)
const n = result.notifications[0]
assert.equal(n.type, 'follow')
assert.equal(n.txid, 'c'.repeat(64))
assert.equal(n.addr, THEIR_ADDR)
})
it('should exclude my own replies', async () => {
const myPostTxid = 'a'.repeat(64)
const replyTxid = 'b'.repeat(64)
postChildrenDb.iterator.returns(makeIterator([
[`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 100 }]
]))
likesDb.iterator.returns(makeIterator([]))
followsDb.iterator.returns(makeIterator([]))
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
postsDb.get.withArgs(replyTxid).resolves({ addr: MY_ADDR, text: 'my own reply' })
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
assert.equal(result.total, 0)
})
it('should exclude replies to posts by other people', async () => {
const theirPostTxid = 'a'.repeat(64)
const replyTxid = 'b'.repeat(64)
postChildrenDb.iterator.returns(makeIterator([
[`${theirPostTxid}:${replyTxid}`, { parentTxid: theirPostTxid, childTxid: replyTxid, blockHeight: 100 }]
]))
likesDb.iterator.returns(makeIterator([]))
followsDb.iterator.returns(makeIterator([]))
postsDb.get.withArgs(theirPostTxid).resolves({ addr: THEIR_ADDR, text: 'alice post' })
postsDb.get.withArgs(replyTxid).resolves({ addr: 'bitcoincash:other', text: 'reply' })
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
assert.equal(result.total, 0)
})
it('should exclude unfollows', async () => {
postChildrenDb.iterator.returns(makeIterator([]))
likesDb.iterator.returns(makeIterator([]))
followsDb.iterator.returns(makeIterator([
[`${THEIR_ADDR}:${MY_HASH160}`, { followerAddr: THEIR_ADDR, followeePkHash: MY_HASH160, unfollow: true, txid: 'c'.repeat(64), blockHeight: 150 }]
]))
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
assert.equal(result.total, 0)
})
it('should sort notifications by block height descending', async () => {
const myPostTxid = 'a'.repeat(64)
const replyTxid = 'b'.repeat(64)
const likeTxid = 'c'.repeat(64)
postChildrenDb.iterator.returns(makeIterator([
[`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 100 }]
]))
likesDb.iterator.returns(makeIterator([
[likeTxid, { addr: THEIR_ADDR, postTxid: myPostTxid, blockHeight: 300 }]
]))
followsDb.iterator.returns(makeIterator([]))
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
postsDb.get.withArgs(replyTxid).resolves({ addr: THEIR_ADDR, text: 'reply' })
const result = await uut.listNotifications(MY_ADDR, { limit: 100, offset: 0 })
assert.equal(result.total, 2)
assert.equal(result.notifications[0].type, 'like')
assert.equal(result.notifications[1].type, 'reply')
})
it('should paginate notifications', async () => {
const myPostTxid = 'a'.repeat(64)
const replyTxid = 'b'.repeat(64)
const likeTxid = 'c'.repeat(64)
postChildrenDb.iterator.returns(makeIterator([
[`${myPostTxid}:${replyTxid}`, { parentTxid: myPostTxid, childTxid: replyTxid, blockHeight: 100 }]
]))
likesDb.iterator.returns(makeIterator([
[likeTxid, { addr: THEIR_ADDR, postTxid: myPostTxid, blockHeight: 300 }]
]))
followsDb.iterator.returns(makeIterator([]))
postsDb.get.withArgs(myPostTxid).resolves({ addr: MY_ADDR, text: 'hello' })
postsDb.get.withArgs(replyTxid).resolves({ addr: THEIR_ADDR, text: 'reply' })
const result = await uut.listNotifications(MY_ADDR, { limit: 1, offset: 0 })
assert.equal(result.total, 2)
assert.equal(result.notifications.length, 1)
assert.equal(result.notifications[0].type, 'like')
})
})
@@ -28,6 +28,12 @@ describe('#PostsRESTController', () => {
posts: [{ txid: 'tx3', addr: 'addr-b', blockHeight: 600200 }],
pagination: { limit: 100, offset: 0, total: 1, hasMore: false }
})
},
listNotifications: {
execute: sandbox.stub().resolves({
notifications: [{ type: 'like', txid: 'tx5', addr: 'addr-c', blockHeight: 600300 }],
pagination: { limit: 100, offset: 0, total: 1, hasMore: false }
})
}
}
})
@@ -120,6 +126,25 @@ describe('#PostsRESTController', () => {
assert.include(ctx.throw.firstCall.args[1], 'boom')
})
it('should return notifications from use case', async () => {
const ctx = {
params: { addr: 'addr-c' },
query: { limit: '25', offset: '0' },
body: null,
throw: sandbox.stub()
}
await uut.getNotifications(ctx)
assert.equal(uut.useCases.listNotifications.execute.callCount, 1)
assert.deepEqual(uut.useCases.listNotifications.execute.firstCall.args[0], {
addr: 'addr-c',
limit: '25',
offset: '0'
})
assert.equal(ctx.body.notifications.length, 1)
assert.equal(ctx.body.notifications[0].type, 'like')
})
it('should return a post thread from use case', async () => {
const ctx = {
params: { txid: 'tx4' },
@@ -21,6 +21,7 @@ describe('#UseCases', () => {
},
topicQuery: {},
searchQuery: {},
notificationsQuery: {},
pollQuery: {}
}
}
@@ -55,6 +56,7 @@ describe('#UseCases', () => {
assert.isNotNull(uut.getPollOptions)
assert.isNotNull(uut.getPollVotes)
assert.isNotNull(uut.searchAll)
assert.isNotNull(uut.listNotifications)
})
})
})
@@ -0,0 +1,91 @@
import { assert } from 'chai'
import sinon from 'sinon'
import ListNotifications from '../../../src/use-cases/list-notifications.js'
describe('#ListNotifications', () => {
let uut
let sandbox
let notificationsQuery
beforeEach(() => {
sandbox = sinon.createSandbox()
notificationsQuery = {
listNotifications: sandbox.stub().resolves({
notifications: [{ type: 'follow', txid: 'tx1', addr: 'addr-a' }],
total: 1
})
}
uut = new ListNotifications({ adapters: { notificationsQuery } })
})
afterEach(() => sandbox.restore())
it('should throw when adapters are missing', () => {
try {
// eslint-disable-next-line no-new
new ListNotifications({})
assert.fail('Expected error')
} catch (err) {
assert.include(err.message, 'Adapters required')
}
})
it('should throw when notificationsQuery adapter is missing', () => {
try {
// eslint-disable-next-line no-new
new ListNotifications({ adapters: {} })
assert.fail('Expected error')
} catch (err) {
assert.include(err.message, 'notificationsQuery adapter required')
}
})
it('should reject a missing addr', async () => {
try {
await uut.execute({})
assert.fail('Expected error')
} catch (err) {
assert.equal(err.status, 400)
assert.include(err.message, 'addr is required')
}
})
it('should pass addr, limit, and offset to the query adapter', async () => {
await uut.execute({ addr: 'addr-a', limit: '10', offset: '5' })
assert.equal(notificationsQuery.listNotifications.callCount, 1)
assert.deepEqual(notificationsQuery.listNotifications.firstCall.args[0], 'addr-a')
assert.deepEqual(notificationsQuery.listNotifications.firstCall.args[1], { limit: 10, offset: 5 })
})
it('should default limit and offset', async () => {
await uut.execute({ addr: 'addr-a' })
assert.deepEqual(notificationsQuery.listNotifications.firstCall.args[1], { limit: 100, offset: 0 })
})
it('should reject limit over 100', async () => {
try {
await uut.execute({ addr: 'addr-a', limit: '101' })
assert.fail('Expected error')
} catch (err) {
assert.equal(err.status, 400)
assert.include(err.message, 'limit cannot exceed')
}
})
it('should attach pagination metadata', async () => {
notificationsQuery.listNotifications.resolves({
notifications: [{ type: 'like', txid: 'tx1', addr: 'addr-a' }],
total: 2
})
const result = await uut.execute({ addr: 'addr-a', limit: '1', offset: '0' })
assert.equal(result.notifications.length, 1)
assert.equal(result.pagination.limit, 1)
assert.equal(result.pagination.offset, 0)
assert.equal(result.pagination.total, 2)
assert.equal(result.pagination.hasMore, true)
})
})