chore: publish pearcord-notifications from pearcord workspace

This commit is contained in:
Pearcord
2026-07-12 23:41:16 -04:00
commit c031f2a200
5 changed files with 691 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
package-lock.json
*.log
.DS_Store
pearcord-storage/
+142
View File
@@ -0,0 +1,142 @@
# pearcord-notifications
Local notification inbox, desktop prefs, and cross-device settings gossip.
**Phase 675 (v0.8.650):** Extended `NOTIFICATION_KIND` (`poll`, `scheduled`, `voice_missed`, `stage`, `boost`, `friend_online`), `roleMentionUserIds` in `evaluateInbound`, `restoreReadStates`, platform `notifications-activity.js` mixin (fanout rate limit, gossip dedupe, partition heal). Bundle: `npm run test:ci-phase675`.
**Phase 670 (v0.8.645):** `notifyOnSticker` desktop pref (default `true`). Bundle: `npm run test:ci-phase670`.
**Phase 669 (v0.8.644):** `notifyOnReaction` desktop pref (default `true`) — DM reaction notifications respect user opt-out. Bundle: `npm run test:ci-phase669`.
## Platform integration (v0.8.461)
`pearcord-platform#markAllNotificationsRead` logs `inbox.mark-all` spans (`unreadBefore`, `remainingUnread`). UI inbox compositor: `syncInboxPanelsDuringGuildLoading`. Verification: `npm run test:phase498-inbox`.
## Mission
Evaluate inbound messages against user prefs (mentions, DMs, replies, all-messages, announcements), write inbox rows, expose unread counts and guild badge aggregation helpers, and sync notification/desktop prefs across devices on `settingsTopic` via Protomux (`pearcord-settings-v1`). Does not render OS notifications itself — platform/companion emits UI events.
## When to use / not
**Use when:**
- You need inbox CRUD or prefs merge logic in a companion process.
- You are testing `SETTINGS_UPDATE` mesh convergence without Pear UI.
**Do not use when:**
- You need push notification gateways (APNs/FCM) — out of scope; desktop flag is pref only.
- You need email digests — use `pearcord-digest-relay-plugin`.
## Public API
| Export | Role |
|--------|------|
| `PearcordNotifications` | `EventEmitter` service |
| `NOTIFICATION_KIND` | `mention`, `dm`, `reply`, `message`, `announcement` |
| `DEFAULT_PREFS` | Desktop + channel mute defaults + `channelNotificationLevels` + `channelMuteUntil` |
| `getChannelNotificationLevel` | Resolve `all` / `mentions` / `nothing` for a channel |
| `setChannelMuteDuration(channelId, durationMs)` | Timed mute (0 clears); updates `channelMuteUntil` + `mutedChannelIds` |
| `pruneExpiredChannelMutes` / `isChannelTimedMuted` | Helpers for expiry |
| `INBOX_COLLECTION` / `PREFS_COLLECTION` | Store keys |
| `ready`, `setUserId` | Init |
| `getPrefs` / `setPrefs(patch, { gossip })` | Prefs + optional mesh fanout |
| `evaluateInbound({ message, mode, channelType, parentChannelName, ... })` | Returns inbox row or null; thread `place` includes parent channel name |
| `push(record)` | Insert + emit `notification` |
| `listRecent`, `countUnread`, `markRead`, `markAllRead`, `clearForChannel` | Inbox |
| `aggregateGuildBadges(channelMeta)` | Server rail badges |
| `joinMesh(swarm, { deviceId, manageListeners })` | Settings topic |
| `ingestSettingsUpdate`, `simulateGossip` | Multi-device prefs |
| `./mesh` | `attachSettingsMesh`, `broadcastSettingsGossip` |
**v0.8.441 (Phase 478):** Platform span metadata extended via `pearcord-platform` wrappers (`wasUnread`, `unreadBefore`, `clearedInboxCount`, `hasMessageId`). UI compositor + clipboard meta. Bundle: `test:phase478-notifications` (app repo).
Platform (v0.8.432) extends spans: `markNotificationRead` `kind`/`read`; `markAllNotificationsRead` `inboxCount`; `openNotificationTarget` `messageId`; `markChannelRead` `threadRead`/`isDm`. UI uses `syncNotificationPanelsDuringGuildLoading` + composer notif hint. Bundle: `test:phase469-notifications-inbox`; smokes: `test:platform-notification-span-metadata-extend`.
Platform (v0.8.416) extends `openNotificationTarget` / `markAllNotificationsRead` / `markChannelRead` spans with `guildId` on `end`. UI inbox panel (Phase 453) adds **All/Unread/Mentions** filter chips and list keyboard roving; guild-loading disables chips and rows via `syncNotifPanelDuringGuildLoading`. Bundle: `test:phase453-notifications-inbox`; smoke: `test:platform-notification-span-guild-id`.
Platform (v0.8.407) wraps `markRead` as `markNotificationRead` with `notification.markRead` span + error; logs `notification.markAll error` and `notification.open error` on inbox navigation failures. Dev-log **notification-errors** filter matches `notification.markRead|markAll|open` error lines (`test:dev-log-notification-errors-bundle`). Phase 444 bundle: `test:phase444-notifications-inbox`.
## P2P surface
| Piece | Detail |
|-------|--------|
| Topic | `settingsTopic(userId)` |
| Protocol | `pearcord-settings-v1` on shared settings swarm |
| RPC | `SETTINGS_UPDATE` (prefs blob + `updatedAt` LWW) |
Platform may share one Hyperswarm between notifications, user settings, and profile cosmetics with multiplexed wire attach.
## Storage
| Collection | Path |
|------------|------|
| `@pearcord/notification-inbox` | `{storagePath}/notifications` |
| `@pearcord/notification-prefs` | same JsonStore |
Inbox rows: `title`, `body`, `place`, `kind`, `read`, `guildId`, `channelId`, `messageId`.
## Platform integration
```javascript
const { PearcordNotifications } = require('pearcord-notifications')
const { attachSettingsMesh } = require('pearcord-notifications/mesh')
this.notifications = new PearcordNotifications({ userId, storagePath })
await this.notifications.ready()
await this.notifications.joinMesh(this._settingsSwarm, { deviceId })
// inbound message → evaluateInbound → push → emit to UI
```
## UI / IPC
| IPC | Platform path |
|-----|----------------|
| `get-notifications` | `listRecent` |
| `set-notification-prefs` | `setPrefs` |
| `mark-notification-read` | `markRead` |
| Companion jump | `NOTIFICATION_KIND` in smokes |
## Related docs
- [NOTIFICATIONS.md](../../docs/NOTIFICATIONS.md)
- [DEVICE_SYNC.md](../../docs/DEVICE_SYNC.md)
- [USER_SETTINGS.md](../../docs/USER_SETTINGS.md)
- [COMPANION.md](../../docs/COMPANION.md)
- [IPC.md](../../docs/IPC.md)
- [MODULES.md](../../docs/MODULES.md)
## Tests
- `smoke-companion-notifications.cjs`, `smoke-companion-notification-jump.cjs`
- `smoke-notification-jump.cjs`, `smoke-notification-prefs-mesh-live.cjs`
- `smoke-companion-boot.cjs` — module resolves
## Code example
```javascript
const { PearcordNotifications, NOTIFICATION_KIND } = require('pearcord-notifications')
const n = new PearcordNotifications({ userId: 'u1', storagePath: './pearcord-storage' })
await n.ready()
await n.setPrefs({ notifyOnMention: true, notifyOnDM: true })
const row = await n.evaluateInbound({
message: { id: 'm1', channelId: 'c1', authorId: 'u2', content: '@u1 hi' },
mode: 'guild',
guildId: 'g1',
mentionedUserIds: ['u1'],
authorName: 'Peer'
})
if (row) {
row.kind === NOTIFICATION_KIND.MENTION
await n.push(row)
}
```
## Repository
Part of **[Pearcord](https://git.ssh.surf/pearcord)**.
- **Org:** [`pearcord`](https://git.ssh.surf/pearcord)
- **Clone:** `git clone https://git.ssh.surf/pearcord/pearcord-notifications.git`
- **Install:** `npm install git+https://git.ssh.surf/pearcord/pearcord-notifications.git#main`
+501
View File
@@ -0,0 +1,501 @@
'use strict'
require('bare-process/global')
const path = require('bare-path')
const EventEmitter = require('bare-events')
const b4a = require('b4a')
const { JsonStore } = require('pearcord-db/store-json')
const { RPC, settingsTopic, topicToBuffer, id, now } = require('pearcord-shared')
const { attachSettingsMesh, broadcastSettingsGossip } = require('./mesh')
const INBOX_COLLECTION = '@pearcord/notification-inbox'
const PREFS_COLLECTION = '@pearcord/notification-prefs'
const NOTIFICATION_KIND = {
MENTION: 'mention',
DM: 'dm',
REPLY: 'reply',
MESSAGE: 'message',
ANNOUNCEMENT: 'announcement',
POLL: 'poll',
SCHEDULED: 'scheduled',
VOICE_MISSED: 'voice_missed',
STAGE: 'stage',
BOOST: 'boost',
FRIEND_ONLINE: 'friend_online'
}
const DEFAULT_PREFS = {
desktopEnabled: true,
/** When true, suppress OS desktop notifications while you are screen sharing (privacy mode). */
suppressDesktopWhileScreenSharing: false,
notifyOnMention: true,
notifyOnDM: true,
notifyOnReply: true,
notifyOnReaction: true,
notifyOnSticker: true,
notifyOnAllMessages: false,
notifyOnAnnouncement: true,
mutedGuildIds: [],
mutedChannelIds: [],
/** Per-channel override: `all` | `mentions` | `nothing` (nothing also listed in mutedChannelIds). */
channelNotificationLevels: {},
/** Timed notification mute: channelId → expiresAt ms (P2P prefs mesh). */
channelMuteUntil: {}
}
function pruneExpiredChannelMutes (prefs, at = Date.now()) {
const until = prefs.channelMuteUntil || {}
const next = { ...until }
let changed = false
for (const [channelId, expiresAt] of Object.entries(until)) {
if (!expiresAt || Number(expiresAt) <= at) {
delete next[channelId]
changed = true
}
}
if (!changed) return prefs
const muted = new Set(prefs.mutedChannelIds || [])
for (const channelId of Object.keys(until)) {
if (!next[channelId] && muted.has(channelId)) {
const level = prefs.channelNotificationLevels?.[channelId]
if (level !== 'nothing') muted.delete(channelId)
}
}
return {
...prefs,
channelMuteUntil: next,
mutedChannelIds: [...muted]
}
}
function isChannelTimedMuted (prefs, channelId, at = Date.now()) {
if (!channelId) return false
const until = Number(prefs.channelMuteUntil?.[channelId]) || 0
return until > at
}
function getChannelNotificationLevel (prefs, channelId) {
if (!channelId) return 'all'
prefs = pruneExpiredChannelMutes(prefs)
if (isChannelTimedMuted(prefs, channelId)) return 'nothing'
if (prefs.mutedChannelIds?.includes(channelId)) return 'nothing'
const level = prefs.channelNotificationLevels?.[channelId]
if (level === 'all' || level === 'mentions' || level === 'nothing') return level
return 'all'
}
function truncate (s, max = 140) {
const t = String(s || '').replace(/\s+/g, ' ').trim()
if (t.length <= max) return t
return t.slice(0, max - 1) + '…'
}
class PearcordNotifications extends EventEmitter {
constructor (opts = {}) {
super()
this.userId = opts.userId || null
this.storagePath = opts.storagePath || './pearcord-storage'
this.store = new JsonStore(path.join(this.storagePath, 'notifications'))
this._prefsCache = null
this._prefsUpdatedAt = 0
this.deviceId = null
this.swarm = null
this._channels = new Map()
this._meshJoined = false
}
async ready () {
await this.store.ready()
return this
}
setUserId (userId) {
this.userId = userId
this._prefsCache = null
}
async getPrefs () {
if (!this.userId) return { ...DEFAULT_PREFS }
if (this._prefsCache) {
const before = JSON.stringify(this._prefsCache.channelMuteUntil || {})
const pruned = pruneExpiredChannelMutes(this._prefsCache)
if (before !== JSON.stringify(pruned.channelMuteUntil || {})) {
this._prefsCache = pruned
await this.store.insert(PREFS_COLLECTION, {
userId: this.userId,
prefs: pruned,
updatedAt: now()
})
} else {
this._prefsCache = pruned
}
return this._prefsCache
}
const row = await this.store.get(PREFS_COLLECTION, { userId: this.userId })
let prefs = { ...DEFAULT_PREFS, ...(row?.prefs || {}) }
prefs = pruneExpiredChannelMutes(prefs)
this._prefsCache = prefs
this._prefsUpdatedAt = row?.updatedAt || 0
return this._prefsCache
}
async setChannelMuteDuration (channelId, durationMs = 0) {
if (!channelId) throw new Error('channelId required')
const prefs = await this.getPrefs()
const until = { ...(prefs.channelMuteUntil || {}) }
const muted = new Set(prefs.mutedChannelIds || [])
const ms = Math.max(0, Number(durationMs) || 0)
if (ms <= 0) {
delete until[channelId]
if (prefs.channelNotificationLevels?.[channelId] !== 'nothing') {
muted.delete(channelId)
}
} else {
const expiresAt = Date.now() + ms
until[channelId] = expiresAt
muted.add(channelId)
}
return this.setPrefs({
channelMuteUntil: until,
mutedChannelIds: [...muted]
})
}
async setPrefs (patch = {}, opts = {}) {
if (!this.userId) throw new Error('userId required')
const prefs = { ...(await this.getPrefs()), ...patch }
const updatedAt = now()
await this.store.insert(PREFS_COLLECTION, {
userId: this.userId,
prefs,
updatedAt
})
this._prefsCache = prefs
this._prefsUpdatedAt = updatedAt
this.emit('prefs', prefs)
if (opts.gossip !== false && this._meshJoined) {
this._broadcastPrefs(updatedAt)
}
return prefs
}
_broadcastPrefs (updatedAt) {
broadcastSettingsGossip(this, RPC.SETTINGS_UPDATE, {
userId: this.userId,
prefs: this._prefsCache,
updatedAt: updatedAt || this._prefsUpdatedAt,
deviceId: this.deviceId || null
})
}
async ingestSettingsUpdate (payload) {
if (!payload || payload.userId !== this.userId) return null
const remoteAt = Number(payload.updatedAt) || 0
if (remoteAt <= (this._prefsUpdatedAt || 0)) return null
const prefs = { ...DEFAULT_PREFS, ...(payload.prefs || {}) }
await this.store.insert(PREFS_COLLECTION, {
userId: this.userId,
prefs,
updatedAt: remoteAt
})
this._prefsCache = prefs
this._prefsUpdatedAt = remoteAt
this.emit('prefs', prefs)
return prefs
}
_onSettingsGossip (method, payload) {
if (method === RPC.SETTINGS_UPDATE) {
this.ingestSettingsUpdate(payload).catch(() => {})
}
}
async simulateGossip (method, payload) {
if (method === RPC.SETTINGS_UPDATE) return this.ingestSettingsUpdate(payload)
this._onSettingsGossip(method, payload)
return null
}
gossipLocal (method, payload) {
broadcastSettingsGossip(this, method, payload)
}
async _flushSwarm (ms = Number(process.env.PEARCORD_MESH_FLUSH_MS || 2500)) {
if (!this.swarm) return
await Promise.race([
this.swarm.flush().catch(() => {}),
new Promise((resolve) => setTimeout(resolve, ms))
])
}
async joinMesh (swarm, { deviceId, manageListeners = true } = {}) {
if (!swarm) throw new Error('swarm required')
if (!this.userId) throw new Error('userId required')
if (this._meshJoined && this.swarm === swarm) return this
await this.leaveMesh({ manageListeners }).catch(() => {})
this.deviceId = deviceId || null
this.swarm = swarm
if (manageListeners) {
this.swarm.removeAllListeners('connection')
this.swarm.on('connection', (conn) => {
const peerId = b4a.toString(conn.remotePublicKey, 'hex')
const ch = attachSettingsMesh(this, conn)
this._channels.set(peerId, ch)
this.emit('peer', { peerId, type: 'join' })
conn.on('close', () => {
this._channels.delete(peerId)
this.emit('peer', { peerId, type: 'leave' })
})
})
}
await this.swarm.join(topicToBuffer(settingsTopic(this.userId)), {
server: true,
client: true
})
await this._flushSwarm(3500)
this._meshJoined = true
this._broadcastPrefs(this._prefsUpdatedAt || now())
return this
}
async leaveMesh ({ manageListeners = true } = {}) {
this._channels.clear()
this._meshJoined = false
if (this.swarm) {
await this.swarm.leave(topicToBuffer(settingsTopic(this.userId))).catch(() => {})
if (manageListeners) this.swarm.removeAllListeners('connection')
}
this.swarm = null
}
getStats () {
return { peers: this._channels.size }
}
_isMuted (prefs, { guildId, channelId }) {
if (prefs.mutedGuildIds?.includes(guildId)) return true
if (getChannelNotificationLevel(prefs, channelId) === 'nothing') return true
return false
}
/**
* Decide whether to record a notification for an inbound message.
*/
async evaluateInbound ({
message,
mode,
guildId,
guildName,
channelName,
channelType = null,
parentChannelName = null,
mentionedUserIds = [],
roleMentionUserIds = [],
replyToAuthorId = null,
activeChannelId = null,
authorName = 'Someone'
}) {
if (!this.userId || !message?.id) return null
if (message.authorId === this.userId) return null
if (activeChannelId && message.channelId === activeChannelId) return null
const prefs = await this.getPrefs()
if (this._isMuted(prefs, { guildId, channelId: message.channelId })) {
return null
}
let kind = null
if (mode === 'dm') {
if (prefs.notifyOnDM) kind = NOTIFICATION_KIND.DM
} else if (
(mentionedUserIds.includes(this.userId) || roleMentionUserIds.includes(this.userId)) &&
prefs.notifyOnMention
) {
kind = NOTIFICATION_KIND.MENTION
} else if (replyToAuthorId === this.userId && prefs.notifyOnReply) {
kind = NOTIFICATION_KIND.REPLY
} else {
const explicit = prefs.channelNotificationLevels?.[message.channelId]
if (explicit === 'all') {
kind = NOTIFICATION_KIND.MESSAGE
} else if (!explicit && prefs.notifyOnAllMessages) {
kind = NOTIFICATION_KIND.MESSAGE
}
}
if (!kind) return null
const place =
mode === 'dm'
? `@${channelName || 'Direct Message'}`
: channelType === 'thread' && parentChannelName
? `#${channelName || 'thread'} · #${parentChannelName} · ${guildName || 'Server'}`
: `#${channelName || 'channel'} · ${guildName || 'Server'}`
const title = kind === NOTIFICATION_KIND.MENTION
? `${authorName} mentioned you`
: kind === NOTIFICATION_KIND.DM
? `Message from ${authorName}`
: kind === NOTIFICATION_KIND.REPLY
? `${authorName} replied to you`
: `New message in ${guildName || 'server'}`
const body = truncate(message.content || '(attachment)')
const record = {
id: id(),
userId: this.userId,
kind,
guildId: guildId || null,
channelId: message.channelId,
messageId: message.id,
authorId: message.authorId,
authorName,
title,
body,
place,
read: false,
createdAt: now()
}
return record
}
async push (record) {
if (!record?.id) return null
await this.store.insert(INBOX_COLLECTION, record)
this.emit('notification', record)
return record
}
async listRecent (limit = 30) {
if (!this.userId) return []
const rows = await this.store.find(INBOX_COLLECTION, { userId: this.userId })
return rows
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0))
.slice(0, limit)
}
async countUnread () {
const rows = await this.listRecent(200)
return rows.filter((r) => !r.read).length
}
async markRead (notificationId) {
const row = await this.store.get(INBOX_COLLECTION, { id: notificationId })
if (!row) return null
row.read = true
row.readAt = now()
await this.store.insert(INBOX_COLLECTION, row)
return row
}
async markAllRead () {
const rows = await this.listRecent(500)
let n = 0
for (const row of rows) {
if (!row.read) {
row.read = true
row.readAt = now()
await this.store.insert(INBOX_COLLECTION, row)
n++
}
}
return n
}
async restoreReadStates (snapshot = []) {
const ids = new Set(
(Array.isArray(snapshot) ? snapshot : [])
.filter((s) => s?.id && s.read === false)
.map((s) => s.id)
)
if (!ids.size) return { restored: 0 }
let restored = 0
for (const nid of ids) {
const row = await this.store.get(INBOX_COLLECTION, { id: nid }).catch(() => null)
if (!row) continue
row.read = false
delete row.readAt
await this.store.insert(INBOX_COLLECTION, row)
restored++
}
return { restored }
}
async clearForChannel (guildId, channelId) {
if (!this.userId) return 0
const rows = await this.listRecent(500)
let n = 0
for (const row of rows) {
if (row.channelId === channelId && (row.guildId === guildId || (!row.guildId && !guildId))) {
row.read = true
row.readAt = now()
await this.store.insert(INBOX_COLLECTION, row)
n++
}
}
return n
}
async clearForGuild (guildId) {
if (!guildId || !this.userId) return 0
const rows = await this.listRecent(500)
let n = 0
for (const row of rows) {
if (row.guildId !== guildId) continue
row.read = true
row.readAt = now()
await this.store.insert(INBOX_COLLECTION, row)
n++
}
return n
}
/**
* Per-guild unread totals for server rail badges (unread + mention weighted).
*/
summarizeGuildUnread ({ unread = {}, mentionAlerts = {}, guildIds = [] }) {
const out = {}
for (const gid of guildIds) {
out[gid] = { unread: 0, mentions: 0, badge: 0 }
}
for (const [channelId, count] of Object.entries(unread)) {
const n = Number(count) || 0
if (!n) continue
// guild id must be passed per channel in extended map — caller supplies guildUnreadByChannel
}
return out
}
/**
* @param {Record<string, { guildId: string, unread: number, mentions: number }>} channelMeta
*/
aggregateGuildBadges (channelMeta = {}) {
const out = {}
for (const meta of Object.values(channelMeta)) {
const gid = meta.guildId
if (!gid) continue
if (!out[gid]) out[gid] = { unread: 0, mentions: 0, badge: 0 }
out[gid].unread += meta.unread || 0
out[gid].mentions += meta.mentions || 0
}
for (const gid of Object.keys(out)) {
const g = out[gid]
g.badge = g.mentions > 0 ? g.mentions : (g.unread > 0 ? g.unread : 0)
g.hasMention = g.mentions > 0
}
return out
}
}
module.exports = {
PearcordNotifications,
NOTIFICATION_KIND,
DEFAULT_PREFS,
INBOX_COLLECTION,
PREFS_COLLECTION,
getChannelNotificationLevel,
pruneExpiredChannelMutes,
isChannelTimedMuted
}
+26
View File
@@ -0,0 +1,26 @@
'use strict'
const { wireSwarmConnection } = require('pearcord-shared')
const {
attachGossipWireSession,
broadcastGossipToSessions
} = require('pearcord-drive/mux-wire')
const SETTINGS_PROTOCOL = 'pearcord-settings-v1'
function attachSettingsMesh (settings, conn) {
wireSwarmConnection(conn)
return attachGossipWireSession({
conn,
protocolV1: SETTINGS_PROTOCOL,
onGossip (method, payload) {
settings._onGossip(method, payload)
}
})
}
async function broadcastSettingsGossip (settings, method, payload) {
await broadcastGossipToSessions(settings._channels, method, payload, { wireReadyMs: 6000 })
}
module.exports = { attachSettingsMesh, broadcastSettingsGossip, SETTINGS_PROTOCOL }
+17
View File
@@ -0,0 +1,17 @@
{
"name": "pearcord-notifications",
"version": "0.1.0",
"main": "index.js",
"type": "commonjs",
"description": "Local notification inbox, prefs, and desktop alert helpers for Pearcord",
"dependencies": {
"bare-events": "^2.8.0",
"bare-path": "^3.0.0",
"b4a": "^1.6.7",
"hyperswarm": "^4.8.0",
"protomux": "^3.0.0",
"pearcord-db": "git+https://git.ssh.surf/pearcord/pearcord-db.git#main",
"pearcord-drive": "git+https://git.ssh.surf/pearcord/pearcord-drive.git#main",
"pearcord-shared": "git+https://git.ssh.surf/pearcord/pearcord-shared.git#main"
}
}