diff --git a/psf-memo-client/acceptance/lib/handlers.js b/psf-memo-client/acceptance/lib/handlers.js index 6cfae7a..6661b23 100644 --- a/psf-memo-client/acceptance/lib/handlers.js +++ b/psf-memo-client/acceptance/lib/handlers.js @@ -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.') + } + } } ] diff --git a/psf-memo-client/src/components/app-body/index.js b/psf-memo-client/src/components/app-body/index.js index d879bbc..dc07b5e 100644 --- a/psf-memo-client/src/components/app-body/index.js +++ b/psf-memo-client/src/components/app-body/index.js @@ -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) { } /> } /> } /> + } /> } /> } /> } /> diff --git a/psf-memo-client/src/components/app-body/notifications/index.js b/psf-memo-client/src/components/app-body/notifications/index.js new file mode 100644 index 0000000..f233282 --- /dev/null +++ b/psf-memo-client/src/components/app-body/notifications/index.js @@ -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 ( + + + +
+

Notifications

+

Replies, likes, and follows involving you.

+ + {pagination && notifications.length > 0 && ( + + Showing {pagination.offset + 1}– + {pagination.offset + notifications.length} of {pagination.total} + + )} +
+ + {error && ( +

+ {error} +

+ )} + + {loading && ( +
+ + + Loading... + + +
+ )} + + {!loading && !error && notifications.length === 0 && ( +

You have no notifications.

+ )} + + {!loading && !error && notifications.length > 0 && ( +
+ {notifications.map((n) => ( +
+

+ {n.addr} +

+

{notificationText(n)}

+
+ ))} +
+ )} + + {!loading && !error && (pagination || offset > 0) && ( +
+ + + +
+ )} + +
+
+ ) +} + +export default Notifications diff --git a/psf-memo-client/src/components/nav-menu/index.js b/psf-memo-client/src/components/nav-menu/index.js index 4d42093..01bc81d 100644 --- a/psf-memo-client/src/components/nav-menu/index.js +++ b/psf-memo-client/src/components/nav-menu/index.js @@ -94,6 +94,14 @@ function NavMenu (props) { Following + + Notifications + + n.txid === txid) || null + } +} + +NotificationsPage.NOTIFICATIONS_PATH = NOTIFICATIONS_PATH + +module.exports = NotificationsPage diff --git a/psf-memo-client/test/unit/notifications-page.test.js b/psf-memo-client/test/unit/notifications-page.test.js new file mode 100644 index 0000000..defb825 --- /dev/null +++ b/psf-memo-client/test/unit/notifications-page.test.js @@ -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') +}) diff --git a/psf-memo-db/src/adapters/index.js b/psf-memo-db/src/adapters/index.js index f18df0c..5c751ca 100644 --- a/psf-memo-db/src/adapters/index.js +++ b/psf-memo-db/src/adapters/index.js @@ -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 } diff --git a/psf-memo-db/src/adapters/notifications-query.js b/psf-memo-db/src/adapters/notifications-query.js new file mode 100644 index 0000000..61184ac --- /dev/null +++ b/psf-memo-db/src/adapters/notifications-query.js @@ -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 diff --git a/psf-memo-db/src/controllers/rest-api/posts/controller.js b/psf-memo-db/src/controllers/rest-api/posts/controller.js index 0332a3a..e924817 100644 --- a/psf-memo-db/src/controllers/rest-api/posts/controller.js +++ b/psf-memo-db/src/controllers/rest-api/posts/controller.js @@ -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({ diff --git a/psf-memo-db/src/controllers/rest-api/posts/index.js b/psf-memo-db/src/controllers/rest-api/posts/index.js index 3d26fca..54c9981 100644 --- a/psf-memo-db/src/controllers/rest-api/posts/index.js +++ b/psf-memo-db/src/controllers/rest-api/posts/index.js @@ -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()) diff --git a/psf-memo-db/src/use-cases/index.js b/psf-memo-db/src/use-cases/index.js index df0a2f2..1ccc1ed 100644 --- a/psf-memo-db/src/use-cases/index.js +++ b/psf-memo-db/src/use-cases/index.js @@ -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 diff --git a/psf-memo-db/src/use-cases/list-notifications.js b/psf-memo-db/src/use-cases/list-notifications.js new file mode 100644 index 0000000..1cd397d --- /dev/null +++ b/psf-memo-db/src/use-cases/list-notifications.js @@ -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 diff --git a/psf-memo-db/test/unit/adapters/notifications-query.unit.js b/psf-memo-db/test/unit/adapters/notifications-query.unit.js new file mode 100644 index 0000000..1ca781b --- /dev/null +++ b/psf-memo-db/test/unit/adapters/notifications-query.unit.js @@ -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') + }) +}) diff --git a/psf-memo-db/test/unit/controllers/posts.controller.unit.js b/psf-memo-db/test/unit/controllers/posts.controller.unit.js index 9405793..ba4a8c1 100644 --- a/psf-memo-db/test/unit/controllers/posts.controller.unit.js +++ b/psf-memo-db/test/unit/controllers/posts.controller.unit.js @@ -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' }, diff --git a/psf-memo-db/test/unit/use-cases/index.unit.js b/psf-memo-db/test/unit/use-cases/index.unit.js index 7d0dda0..9cb4e78 100644 --- a/psf-memo-db/test/unit/use-cases/index.unit.js +++ b/psf-memo-db/test/unit/use-cases/index.unit.js @@ -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) }) }) }) diff --git a/psf-memo-db/test/unit/use-cases/list-notifications.unit.js b/psf-memo-db/test/unit/use-cases/list-notifications.unit.js new file mode 100644 index 0000000..84b94af --- /dev/null +++ b/psf-memo-db/test/unit/use-cases/list-notifications.unit.js @@ -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) + }) +})