chore: publish pearcord-notifications from pearcord workspace
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
package-lock.json
|
||||
*.log
|
||||
.DS_Store
|
||||
pearcord-storage/
|
||||
@@ -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`
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user