payload-notifications 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +164 -0
- package/dist/api.d.ts +11 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +144 -0
- package/dist/api.js.map +1 -0
- package/dist/components/NotificationBell.d.ts +4 -0
- package/dist/components/NotificationBell.d.ts.map +1 -0
- package/dist/components/NotificationBell.js +318 -0
- package/dist/components/NotificationBell.js.map +1 -0
- package/dist/components/NotificationBell.module.css +184 -0
- package/dist/components/NotificationItem.d.ts +11 -0
- package/dist/components/NotificationItem.d.ts.map +1 -0
- package/dist/components/NotificationItem.js +97 -0
- package/dist/components/NotificationItem.js.map +1 -0
- package/dist/components/NotificationItem.module.css +136 -0
- package/dist/components/notification-reducer.d.ts +36 -0
- package/dist/components/notification-reducer.d.ts.map +1 -0
- package/dist/components/notification-reducer.js +74 -0
- package/dist/components/notification-reducer.js.map +1 -0
- package/dist/const.d.ts +2 -0
- package/dist/const.d.ts.map +1 -0
- package/dist/const.js +3 -0
- package/dist/const.js.map +1 -0
- package/dist/context.d.ts +19 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +20 -0
- package/dist/context.js.map +1 -0
- package/dist/email/default-email.d.ts +10 -0
- package/dist/email/default-email.d.ts.map +1 -0
- package/dist/email/default-email.js +34 -0
- package/dist/email/default-email.js.map +1 -0
- package/dist/email/email-token.d.ts +17 -0
- package/dist/email/email-token.d.ts.map +1 -0
- package/dist/email/email-token.js +26 -0
- package/dist/email/email-token.js.map +1 -0
- package/dist/email/index.d.ts +2 -0
- package/dist/email/index.d.ts.map +1 -0
- package/dist/email/index.js +3 -0
- package/dist/email/index.js.map +1 -0
- package/dist/email/send-email.d.ts +11 -0
- package/dist/email/send-email.d.ts.map +1 -0
- package/dist/email/send-email.js +48 -0
- package/dist/email/send-email.js.map +1 -0
- package/dist/endpoints/delete-notification.d.ts +3 -0
- package/dist/endpoints/delete-notification.d.ts.map +1 -0
- package/dist/endpoints/delete-notification.js +20 -0
- package/dist/endpoints/delete-notification.js.map +1 -0
- package/dist/endpoints/email-unsubscribe.d.ts +2 -0
- package/dist/endpoints/email-unsubscribe.d.ts.map +1 -0
- package/dist/endpoints/email-unsubscribe.js +39 -0
- package/dist/endpoints/email-unsubscribe.js.map +1 -0
- package/dist/endpoints/map-notification.d.ts +5 -0
- package/dist/endpoints/map-notification.d.ts.map +1 -0
- package/dist/endpoints/map-notification.js +19 -0
- package/dist/endpoints/map-notification.js.map +1 -0
- package/dist/endpoints/mark-all-read.d.ts +3 -0
- package/dist/endpoints/mark-all-read.d.ts.map +1 -0
- package/dist/endpoints/mark-all-read.js +36 -0
- package/dist/endpoints/mark-all-read.js.map +1 -0
- package/dist/endpoints/mark-read.d.ts +3 -0
- package/dist/endpoints/mark-read.d.ts.map +1 -0
- package/dist/endpoints/mark-read.js +23 -0
- package/dist/endpoints/mark-read.js.map +1 -0
- package/dist/endpoints/open-notification.d.ts +7 -0
- package/dist/endpoints/open-notification.d.ts.map +1 -0
- package/dist/endpoints/open-notification.js +65 -0
- package/dist/endpoints/open-notification.js.map +1 -0
- package/dist/endpoints/read-notifications.d.ts +3 -0
- package/dist/endpoints/read-notifications.d.ts.map +1 -0
- package/dist/endpoints/read-notifications.js +39 -0
- package/dist/endpoints/read-notifications.js.map +1 -0
- package/dist/endpoints/unread-notifications.d.ts +3 -0
- package/dist/endpoints/unread-notifications.d.ts.map +1 -0
- package/dist/endpoints/unread-notifications.js +69 -0
- package/dist/endpoints/unread-notifications.js.map +1 -0
- package/dist/endpoints/unsubscribe.d.ts +2 -0
- package/dist/endpoints/unsubscribe.d.ts.map +1 -0
- package/dist/endpoints/unsubscribe.js +17 -0
- package/dist/endpoints/unsubscribe.js.map +1 -0
- package/dist/endpoints/update-preferences.d.ts +2 -0
- package/dist/endpoints/update-preferences.d.ts.map +1 -0
- package/dist/endpoints/update-preferences.js +33 -0
- package/dist/endpoints/update-preferences.js.map +1 -0
- package/dist/entities.d.ts +7 -0
- package/dist/entities.d.ts.map +1 -0
- package/dist/entities.js +161 -0
- package/dist/entities.js.map +1 -0
- package/dist/exports/client.d.ts +2 -0
- package/dist/exports/client.d.ts.map +1 -0
- package/dist/exports/client.js +4 -0
- package/dist/exports/client.js.map +1 -0
- package/dist/exports/rsc.d.ts +2 -0
- package/dist/exports/rsc.d.ts.map +1 -0
- package/dist/exports/rsc.js +3 -0
- package/dist/exports/rsc.js.map +1 -0
- package/dist/helpers.d.ts +14 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.js +50 -0
- package/dist/helpers.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +75 -0
- package/dist/index.js.map +1 -0
- package/dist/internals/index.d.ts +30 -0
- package/dist/internals/index.d.ts.map +1 -0
- package/dist/internals/index.js +81 -0
- package/dist/internals/index.js.map +1 -0
- package/dist/internals/procedure.d.ts +34 -0
- package/dist/internals/procedure.d.ts.map +1 -0
- package/dist/internals/procedure.js +111 -0
- package/dist/internals/procedure.js.map +1 -0
- package/dist/internals/urls.d.ts +11 -0
- package/dist/internals/urls.d.ts.map +1 -0
- package/dist/internals/urls.js +23 -0
- package/dist/internals/urls.js.map +1 -0
- package/dist/internals/utils.d.ts +8 -0
- package/dist/internals/utils.d.ts.map +1 -0
- package/dist/internals/utils.js +57 -0
- package/dist/internals/utils.js.map +1 -0
- package/dist/message/builder.d.ts +23 -0
- package/dist/message/builder.d.ts.map +1 -0
- package/dist/message/builder.js +43 -0
- package/dist/message/builder.js.map +1 -0
- package/dist/message/index.d.ts +3 -0
- package/dist/message/index.d.ts.map +1 -0
- package/dist/message/index.js +4 -0
- package/dist/message/index.js.map +1 -0
- package/dist/message/resolve-message.d.ts +23 -0
- package/dist/message/resolve-message.d.ts.map +1 -0
- package/dist/message/resolve-message.js +72 -0
- package/dist/message/resolve-message.js.map +1 -0
- package/dist/payload-types.d.ts +317 -0
- package/dist/payload-types.d.ts.map +1 -0
- package/dist/payload-types.js +15 -0
- package/dist/payload-types.js.map +1 -0
- package/dist/procedures.d.ts +58 -0
- package/dist/procedures.d.ts.map +1 -0
- package/dist/procedures.js +62 -0
- package/dist/procedures.js.map +1 -0
- package/dist/types.d.ts +123 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +35 -0
- package/dist/types.js.map +1 -0
- package/package.json +72 -0
package/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# payload-notifications
|
|
2
|
+
|
|
3
|
+
Multi-channel notification infrastructure for Payload CMS with document subscriptions and live messages.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/payload-notifications)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Payload has no built-in way to notify users about events. This plugin provides a server-side `notify()` / `subscribe()` / `getSubscribers()` API that you wire into your own hooks, endpoints, and workflows. A single `notify()` call can store an in-app notification, send an email, and fire an external callback -- each channel independent and optional. Everything is user-scoped with strict access control.
|
|
11
|
+
|
|
12
|
+
**Features**
|
|
13
|
+
|
|
14
|
+
- **Multi-channel delivery** -- in-app, email, and external callbacks from a single `notify()` call. Per-user preferences control which channels are active.
|
|
15
|
+
- **Document subscriptions** -- users follow collections or globals. `getSubscribers()` returns follower IDs for fan-out. Supports auto-subscribe (e.g. on first comment) and manual follows.
|
|
16
|
+
- **Live messages** -- template tokens (`t.actor`, `t.document('title')`, `t.meta('key')`) that resolve against fresh data at read time. Renamed users and updated titles are reflected in existing notifications.
|
|
17
|
+
- **External callback** -- `onNotify` hook for pushing to Slack, webhooks, queues, or anything else.
|
|
18
|
+
- **Admin bell component** -- optional popover UI with unread badge, mark-read, unsubscribe, and delete actions.
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```sh
|
|
23
|
+
pnpm add payload-notifications
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
// payload.config.ts
|
|
30
|
+
import { buildConfig } from "payload";
|
|
31
|
+
import { notificationsPlugin } from "payload-notifications";
|
|
32
|
+
|
|
33
|
+
export default buildConfig({
|
|
34
|
+
// ...
|
|
35
|
+
plugins: [
|
|
36
|
+
notificationsPlugin({
|
|
37
|
+
email: {
|
|
38
|
+
generateSubject: ({ notification }) =>
|
|
39
|
+
`Notification: ${notification.event}`,
|
|
40
|
+
generateHTML: ({ notification, recipient }) =>
|
|
41
|
+
`<p>Hi ${recipient.displayName}, ${notification.message}</p>`,
|
|
42
|
+
},
|
|
43
|
+
}),
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Sending notifications
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { notify } from "payload-notifications";
|
|
52
|
+
|
|
53
|
+
await notify(req, {
|
|
54
|
+
recipient: user.id,
|
|
55
|
+
event: "comment.created",
|
|
56
|
+
actor: commenter.id,
|
|
57
|
+
message: "Someone replied to your post",
|
|
58
|
+
documentReference: { entity: "collection", slug: "posts", id: post.id },
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Subscriptions and fan-out
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
import {
|
|
66
|
+
subscribe,
|
|
67
|
+
getSubscribers,
|
|
68
|
+
notify,
|
|
69
|
+
createLiveMessage,
|
|
70
|
+
} from "payload-notifications";
|
|
71
|
+
|
|
72
|
+
const docRef = { entity: "collection", slug: "posts", id: post.id } as const;
|
|
73
|
+
|
|
74
|
+
await subscribe(req, { userId: commenter.id, documentReference: docRef });
|
|
75
|
+
|
|
76
|
+
const subscribers = await getSubscribers(req, docRef);
|
|
77
|
+
for (const recipientId of subscribers) {
|
|
78
|
+
await notify(req, {
|
|
79
|
+
recipient: recipientId,
|
|
80
|
+
event: "comment.created",
|
|
81
|
+
actor: commenter.id,
|
|
82
|
+
message: createLiveMessage(
|
|
83
|
+
(t) => t`${t.actor} commented on "${t.document("title")}"`,
|
|
84
|
+
),
|
|
85
|
+
documentReference: docRef,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
### Live messages
|
|
91
|
+
|
|
92
|
+
Static strings go stale when users rename themselves or documents get updated. Live messages store tokens that resolve against fresh data at read time:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { notify, createLiveMessage } from "payload-notifications";
|
|
96
|
+
|
|
97
|
+
await notify(req, {
|
|
98
|
+
recipient: user.id,
|
|
99
|
+
event: "post.updated",
|
|
100
|
+
actor: editor.id,
|
|
101
|
+
message: createLiveMessage(
|
|
102
|
+
(t) => t`${t.actor} edited "${t.document("title")}"`,
|
|
103
|
+
),
|
|
104
|
+
documentReference: { entity: "collection", slug: "posts", id: post.id },
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
- `t.actor` -- actor's display name (from `admin.useAsTitle` on the user collection)
|
|
109
|
+
- `t.document(field)` -- a field from the referenced document
|
|
110
|
+
- `t.meta(key)` -- a key from the notification's `meta` object
|
|
111
|
+
|
|
112
|
+
### Options
|
|
113
|
+
|
|
114
|
+
| Option | Type | Default | Description |
|
|
115
|
+
| ------------------- | ------------------------- | ----------------- | ------------------------------------------------------------------- |
|
|
116
|
+
| `email` | `NotificationEmailConfig` | -- | `generateSubject` and `generateHTML` functions. Omit to skip email. |
|
|
117
|
+
| `onNotify` | `NotifactionCallback` | -- | Callback for every notification (Slack, webhooks, queues, etc). |
|
|
118
|
+
| `notificationsSlug` | `string` | `"notifications"` | Slug for the notifications collection. |
|
|
119
|
+
| `subscriptionsSlug` | `string` | `"subscriptions"` | Slug for the subscriptions collection. |
|
|
120
|
+
| `pollInterval` | `number` | `30` | Poll interval in seconds for the admin bell component. |
|
|
121
|
+
|
|
122
|
+
### API
|
|
123
|
+
|
|
124
|
+
All functions are server-side and require a `PayloadRequest`:
|
|
125
|
+
|
|
126
|
+
| Export | Description |
|
|
127
|
+
| ------------------------------- | -------------------------------------------------------------- |
|
|
128
|
+
| `notify(req, input)` | Deliver a notification. Respects per-user channel preferences. |
|
|
129
|
+
| `subscribe(req, opts)` | Subscribe a user to a document. Deduplicates automatically. |
|
|
130
|
+
| `unsubscribe(req, userId, ref)` | Remove a subscription. |
|
|
131
|
+
| `getSubscribers(req, ref)` | Return all user IDs subscribed to a document. |
|
|
132
|
+
| `createLiveMessage(fn)` | Build a serializable message template with dynamic tokens. |
|
|
133
|
+
|
|
134
|
+
## Contributing
|
|
135
|
+
|
|
136
|
+
This plugin lives in the [payload-plugins](https://github.com/davincicoding-org/payload-plugins) monorepo.
|
|
137
|
+
|
|
138
|
+
### Development
|
|
139
|
+
|
|
140
|
+
```sh
|
|
141
|
+
pnpm install
|
|
142
|
+
|
|
143
|
+
# watch this plugin for changes
|
|
144
|
+
pnpm --filter payload-notifications dev
|
|
145
|
+
|
|
146
|
+
# run the Payload dev app (in a second terminal)
|
|
147
|
+
pnpm --filter sandbox dev
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
The `sandbox/` directory is a Next.js + Payload app that imports plugins via `workspace:*` -- use it to test changes locally.
|
|
151
|
+
|
|
152
|
+
### Code quality
|
|
153
|
+
|
|
154
|
+
- **Formatting & linting** -- handled by [Biome](https://biomejs.dev/), enforced on commit via husky + lint-staged.
|
|
155
|
+
- **Commits** -- must follow [Conventional Commits](https://www.conventionalcommits.org/) with a valid scope (e.g. `fix(payload-notifications): ...`).
|
|
156
|
+
- **Changesets** -- please include a [changeset](https://github.com/changesets/changesets) in your PR by running `pnpm release`.
|
|
157
|
+
|
|
158
|
+
### Issues & PRs
|
|
159
|
+
|
|
160
|
+
Bug reports and feature requests are welcome -- [open an issue](https://github.com/davincicoding-org/payload-plugins/issues).
|
|
161
|
+
|
|
162
|
+
## License
|
|
163
|
+
|
|
164
|
+
MIT
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type DocumentID, type DocumentReference } from './internals/index.js';
|
|
2
|
+
import type { PayloadRequest, TypeWithID } from 'payload';
|
|
3
|
+
import type { NotifyInput } from './types';
|
|
4
|
+
export declare function notify<Actor extends DocumentID | null>(req: PayloadRequest, input: NotifyInput<Actor>): Promise<void>;
|
|
5
|
+
export declare function subscribe(req: PayloadRequest, { userId, documentReference, }: {
|
|
6
|
+
userId: string | number;
|
|
7
|
+
documentReference: DocumentReference;
|
|
8
|
+
}): Promise<void>;
|
|
9
|
+
export declare function unsubscribe(req: PayloadRequest, userId: string | number, documentReference: DocumentReference): Promise<void>;
|
|
10
|
+
export declare function getSubscribers(req: PayloadRequest, documentReference: DocumentReference): Promise<TypeWithID['id'][]>;
|
|
11
|
+
//# sourceMappingURL=api.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"api.d.ts","sourceRoot":"","sources":["../src/api.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,KAAK,UAAU,EACf,KAAK,iBAAiB,EAEvB,MAAM,cAAc,CAAC;AACtB,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAS,MAAM,SAAS,CAAC;AAKjE,OAAO,KAAK,EAEV,WAAW,EAGZ,MAAM,SAAS,CAAC;AAEjB,wBAAsB,MAAM,CAAC,KAAK,SAAS,UAAU,GAAG,IAAI,EAC1D,GAAG,EAAE,cAAc,EACnB,KAAK,EAAE,WAAW,CAAC,KAAK,CAAC,GACxB,OAAO,CAAC,IAAI,CAAC,CAyEf;AAED,wBAAsB,SAAS,CAC7B,GAAG,EAAE,cAAc,EACnB,EACE,MAAM,EACN,iBAAiB,GAClB,EAAE;IACD,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,iBAAiB,EAAE,iBAAiB,CAAC;CACtC,GACA,OAAO,CAAC,IAAI,CAAC,CAsBf;AAED,wBAAsB,WAAW,CAC/B,GAAG,EAAE,cAAc,EACnB,MAAM,EAAE,MAAM,GAAG,MAAM,EACvB,iBAAiB,EAAE,iBAAiB,GACnC,OAAO,CAAC,IAAI,CAAC,CAWf;AAED,wBAAsB,cAAc,CAClC,GAAG,EAAE,cAAc,EACnB,iBAAiB,EAAE,iBAAiB,GACnC,OAAO,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC,CAgB7B"}
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { fetchDocumentByReference } from './internals/index.js';
|
|
2
|
+
import { getPluginContext } from './context';
|
|
3
|
+
import { sendNotificationEmail } from './email';
|
|
4
|
+
import { resolveUser, toStoredReference } from './helpers';
|
|
5
|
+
import { resolveMessageAtReadTime, toMessage } from './message';
|
|
6
|
+
export async function notify(req, input) {
|
|
7
|
+
const ctx = getPluginContext(req.payload.config);
|
|
8
|
+
if (!req.payload.config.admin?.user) return;
|
|
9
|
+
// Resolve actor display name if provided
|
|
10
|
+
const actor = input.actor ? await resolveUser(req.payload, input.actor) : null;
|
|
11
|
+
// Fetch the referenced document if provided
|
|
12
|
+
const document = input.documentReference ? await fetchDocumentByReference(req.payload, input.documentReference) : undefined;
|
|
13
|
+
// Build message context from resolved data
|
|
14
|
+
const messageContext = {
|
|
15
|
+
actor: actor,
|
|
16
|
+
document: document,
|
|
17
|
+
meta: input.meta
|
|
18
|
+
};
|
|
19
|
+
// Convert message to stored format and resolve a plain string for email/callback
|
|
20
|
+
const serializedMessage = toMessage(input.message, messageContext);
|
|
21
|
+
const resolvedMessageString = resolveMessageAtReadTime(serializedMessage, messageContext);
|
|
22
|
+
const recipient = await resolveUser(req.payload, input.recipient);
|
|
23
|
+
const notificationDoc = await req.payload.create({
|
|
24
|
+
collection: ctx.collectionSlugs.notifications,
|
|
25
|
+
data: {
|
|
26
|
+
recipient: input.recipient,
|
|
27
|
+
event: input.event,
|
|
28
|
+
actor: input.actor,
|
|
29
|
+
message: serializedMessage,
|
|
30
|
+
url: input.url,
|
|
31
|
+
meta: input.meta,
|
|
32
|
+
documentReference: input.documentReference ? toStoredReference(input.documentReference) : undefined
|
|
33
|
+
},
|
|
34
|
+
req
|
|
35
|
+
});
|
|
36
|
+
if (ctx.email && recipient.notificationPreferences?.emailEnabled) {
|
|
37
|
+
await sendNotificationEmail(req, {
|
|
38
|
+
emailConfig: ctx.email,
|
|
39
|
+
notification: {
|
|
40
|
+
message: resolvedMessageString,
|
|
41
|
+
event: input.event
|
|
42
|
+
},
|
|
43
|
+
recipient,
|
|
44
|
+
notificationId: notificationDoc.id,
|
|
45
|
+
documentReference: input.documentReference
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
if (ctx.onNotify) {
|
|
49
|
+
try {
|
|
50
|
+
await ctx.onNotify({
|
|
51
|
+
req,
|
|
52
|
+
notification: {
|
|
53
|
+
message: resolvedMessageString,
|
|
54
|
+
event: input.event
|
|
55
|
+
},
|
|
56
|
+
recipient
|
|
57
|
+
});
|
|
58
|
+
} catch (err) {
|
|
59
|
+
console.error('[payload-notifications] onNotify callback failed:', err);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
export async function subscribe(req, { userId, documentReference }) {
|
|
64
|
+
const ctx = getPluginContext(req.payload.config);
|
|
65
|
+
const ref = toStoredReference(documentReference);
|
|
66
|
+
const existing = await req.payload.find({
|
|
67
|
+
collection: ctx.collectionSlugs.subscriptions,
|
|
68
|
+
where: {
|
|
69
|
+
and: [
|
|
70
|
+
{
|
|
71
|
+
user: {
|
|
72
|
+
equals: userId
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
...documentReferenceWhere(ref)
|
|
76
|
+
]
|
|
77
|
+
},
|
|
78
|
+
limit: 1
|
|
79
|
+
});
|
|
80
|
+
if (existing.totalDocs > 0) return;
|
|
81
|
+
await req.payload.create({
|
|
82
|
+
collection: ctx.collectionSlugs.subscriptions,
|
|
83
|
+
data: {
|
|
84
|
+
user: userId,
|
|
85
|
+
documentReference: ref
|
|
86
|
+
},
|
|
87
|
+
req
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
export async function unsubscribe(req, userId, documentReference) {
|
|
91
|
+
const ctx = getPluginContext(req.payload.config);
|
|
92
|
+
const ref = toStoredReference(documentReference);
|
|
93
|
+
await req.payload.delete({
|
|
94
|
+
collection: ctx.collectionSlugs.subscriptions,
|
|
95
|
+
where: {
|
|
96
|
+
and: [
|
|
97
|
+
{
|
|
98
|
+
user: {
|
|
99
|
+
equals: userId
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
...documentReferenceWhere(ref)
|
|
103
|
+
]
|
|
104
|
+
},
|
|
105
|
+
req
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
export async function getSubscribers(req, documentReference) {
|
|
109
|
+
const ctx = getPluginContext(req.payload.config);
|
|
110
|
+
const ref = toStoredReference(documentReference);
|
|
111
|
+
const results = await req.payload.find({
|
|
112
|
+
collection: ctx.collectionSlugs.subscriptions,
|
|
113
|
+
where: {
|
|
114
|
+
and: documentReferenceWhere(ref)
|
|
115
|
+
},
|
|
116
|
+
limit: 0,
|
|
117
|
+
depth: 0
|
|
118
|
+
});
|
|
119
|
+
return results.docs.map(({ user })=>typeof user === 'object' ? user.id : user);
|
|
120
|
+
}
|
|
121
|
+
/** Build the where clause to match a stored document reference. */ function documentReferenceWhere(ref) {
|
|
122
|
+
const conditions = [
|
|
123
|
+
{
|
|
124
|
+
'documentReference.entity': {
|
|
125
|
+
equals: ref.entity
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
'documentReference.slug': {
|
|
130
|
+
equals: ref.slug
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
];
|
|
134
|
+
if (ref.documentId) {
|
|
135
|
+
conditions.push({
|
|
136
|
+
'documentReference.documentId': {
|
|
137
|
+
equals: ref.documentId
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
return conditions;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
//# sourceMappingURL=api.js.map
|
package/dist/api.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/api.ts"],"sourcesContent":["import {\n type DocumentID,\n type DocumentReference,\n fetchDocumentByReference,\n} from '@repo/common';\nimport type { PayloadRequest, TypeWithID, Where } from 'payload';\nimport { getPluginContext } from './context';\nimport { sendNotificationEmail } from './email';\nimport { resolveUser, toStoredReference } from './helpers';\nimport { resolveMessageAtReadTime, toMessage } from './message';\nimport type {\n MessageContext,\n NotifyInput,\n ResolvedActor,\n StoredDocumentReference,\n} from './types';\n\nexport async function notify<Actor extends DocumentID | null>(\n req: PayloadRequest,\n input: NotifyInput<Actor>,\n): Promise<void> {\n const ctx = getPluginContext(req.payload.config);\n if (!req.payload.config.admin?.user) return;\n\n // Resolve actor display name if provided\n const actor = input.actor\n ? await resolveUser(req.payload, input.actor)\n : null;\n\n // Fetch the referenced document if provided\n const document = input.documentReference\n ? await fetchDocumentByReference(req.payload, input.documentReference)\n : undefined;\n\n // Build message context from resolved data\n const messageContext: MessageContext<Actor> = {\n actor: actor as Actor extends DocumentID ? ResolvedActor : null,\n document: document as Record<string, unknown> | undefined,\n meta: input.meta,\n };\n\n // Convert message to stored format and resolve a plain string for email/callback\n const serializedMessage = toMessage(input.message, messageContext);\n const resolvedMessageString = resolveMessageAtReadTime(\n serializedMessage,\n messageContext,\n );\n\n const recipient = await resolveUser(req.payload, input.recipient);\n\n const notificationDoc = await req.payload.create({\n collection: ctx.collectionSlugs.notifications as 'notifications',\n data: {\n recipient: input.recipient as string,\n event: input.event,\n actor: input.actor as string,\n message: serializedMessage,\n url: input.url,\n meta: input.meta,\n documentReference: input.documentReference\n ? toStoredReference(input.documentReference)\n : undefined,\n },\n req,\n });\n\n if (ctx.email && recipient.notificationPreferences?.emailEnabled) {\n await sendNotificationEmail(req, {\n emailConfig: ctx.email,\n notification: {\n message: resolvedMessageString,\n event: input.event,\n },\n recipient,\n notificationId: notificationDoc.id,\n documentReference: input.documentReference,\n });\n }\n\n if (ctx.onNotify) {\n try {\n await ctx.onNotify({\n req,\n notification: {\n message: resolvedMessageString,\n event: input.event,\n },\n recipient,\n });\n } catch (err) {\n console.error('[payload-notifications] onNotify callback failed:', err);\n }\n }\n}\n\nexport async function subscribe(\n req: PayloadRequest,\n {\n userId,\n documentReference,\n }: {\n userId: string | number;\n documentReference: DocumentReference;\n },\n): Promise<void> {\n const ctx = getPluginContext(req.payload.config);\n const ref = toStoredReference(documentReference);\n\n const existing = await req.payload.find({\n collection: ctx.collectionSlugs.subscriptions as 'subscriptions',\n where: {\n and: [{ user: { equals: userId } }, ...documentReferenceWhere(ref)],\n },\n limit: 1,\n });\n\n if (existing.totalDocs > 0) return;\n\n await req.payload.create({\n collection: ctx.collectionSlugs.subscriptions as 'subscriptions',\n data: {\n user: userId as string,\n documentReference: ref,\n },\n req,\n });\n}\n\nexport async function unsubscribe(\n req: PayloadRequest,\n userId: string | number,\n documentReference: DocumentReference,\n): Promise<void> {\n const ctx = getPluginContext(req.payload.config);\n const ref = toStoredReference(documentReference);\n\n await req.payload.delete({\n collection: ctx.collectionSlugs.subscriptions as 'subscriptions',\n where: {\n and: [{ user: { equals: userId } }, ...documentReferenceWhere(ref)],\n },\n req,\n });\n}\n\nexport async function getSubscribers(\n req: PayloadRequest,\n documentReference: DocumentReference,\n): Promise<TypeWithID['id'][]> {\n const ctx = getPluginContext(req.payload.config);\n const ref = toStoredReference(documentReference);\n\n const results = await req.payload.find({\n collection: ctx.collectionSlugs.subscriptions as 'subscriptions',\n where: {\n and: documentReferenceWhere(ref),\n },\n limit: 0,\n depth: 0,\n });\n\n return results.docs.map(({ user }) =>\n typeof user === 'object' ? user.id : user,\n );\n}\n\n/** Build the where clause to match a stored document reference. */\nfunction documentReferenceWhere(ref: StoredDocumentReference): Where[] {\n const conditions: Where[] = [\n { 'documentReference.entity': { equals: ref.entity } },\n { 'documentReference.slug': { equals: ref.slug } },\n ];\n if (ref.documentId) {\n conditions.push({\n 'documentReference.documentId': { equals: ref.documentId },\n });\n }\n return conditions;\n}\n"],"names":["fetchDocumentByReference","getPluginContext","sendNotificationEmail","resolveUser","toStoredReference","resolveMessageAtReadTime","toMessage","notify","req","input","ctx","payload","config","admin","user","actor","document","documentReference","undefined","messageContext","meta","serializedMessage","message","resolvedMessageString","recipient","notificationDoc","create","collection","collectionSlugs","notifications","data","event","url","email","notificationPreferences","emailEnabled","emailConfig","notification","notificationId","id","onNotify","err","console","error","subscribe","userId","ref","existing","find","subscriptions","where","and","equals","documentReferenceWhere","limit","totalDocs","unsubscribe","delete","getSubscribers","results","depth","docs","map","conditions","entity","slug","documentId","push"],"mappings":"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"}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { ResolvedPluginOptions } from '../types';
|
|
2
|
+
export type NotificationBellProps = ResolvedPluginOptions<'pollInterval'>;
|
|
3
|
+
export declare function NotificationBell({ pollInterval }: NotificationBellProps): import("react/jsx-runtime").JSX.Element;
|
|
4
|
+
//# sourceMappingURL=NotificationBell.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"NotificationBell.d.ts","sourceRoot":"","sources":["../../src/components/NotificationBell.tsx"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,qBAAqB,EAA2B,MAAM,SAAS,CAAC;AAU9E,MAAM,MAAM,qBAAqB,GAAG,qBAAqB,CAAC,cAAc,CAAC,CAAC;AAE1E,wBAAgB,gBAAgB,CAAC,EAAE,YAAY,EAAE,EAAE,qBAAqB,2CA+IvE"}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Collapsible } from '@base-ui/react/collapsible';
|
|
4
|
+
import { Popover } from '@base-ui/react/popover';
|
|
5
|
+
import { Button, useAuth, useConfig } from '@payloadcms/ui';
|
|
6
|
+
import { IconAdjustments, IconBell } from '@tabler/icons-react';
|
|
7
|
+
import { useCallback, useEffect, useReducer, useRef, useState } from 'react';
|
|
8
|
+
import { toDocumentReference } from '../helpers';
|
|
9
|
+
import { ENDPOINTS } from '../procedures';
|
|
10
|
+
import styles from './NotificationBell.module.css';
|
|
11
|
+
import { NotificationItem } from './NotificationItem';
|
|
12
|
+
import { INITIAL_STATE, notificationReducer } from './notification-reducer';
|
|
13
|
+
export function NotificationBell({ pollInterval }) {
|
|
14
|
+
const { config: { routes: { api: apiRoute } } } = useConfig();
|
|
15
|
+
const { user } = useAuth();
|
|
16
|
+
const [mounted, setMounted] = useState(false);
|
|
17
|
+
useEffect(()=>setMounted(true), []);
|
|
18
|
+
const [state, dispatch] = useReducer(notificationReducer, INITIAL_STATE);
|
|
19
|
+
useUnreadPolling(apiRoute, pollInterval, state, dispatch, mounted);
|
|
20
|
+
const { loadMore, isLoadingRead } = useReadNotifications(apiRoute, state, dispatch);
|
|
21
|
+
const { markRead, markAllRead, deleteNotification } = useNotificationActions(apiRoute, dispatch);
|
|
22
|
+
const { prefs, togglePref, unsubscribe } = useNotificationPreferences(apiRoute, user);
|
|
23
|
+
const bellIcon = /*#__PURE__*/ _jsxs("div", {
|
|
24
|
+
className: styles.bellIcon,
|
|
25
|
+
children: [
|
|
26
|
+
/*#__PURE__*/ _jsx(IconBell, {
|
|
27
|
+
size: 20,
|
|
28
|
+
strokeWidth: 1.5
|
|
29
|
+
}),
|
|
30
|
+
state.unread.length > 0 && /*#__PURE__*/ _jsx("span", {
|
|
31
|
+
className: styles.indicator,
|
|
32
|
+
children: state.unread.length
|
|
33
|
+
})
|
|
34
|
+
]
|
|
35
|
+
});
|
|
36
|
+
// Render a static bell during SSR to avoid hydration mismatch from
|
|
37
|
+
// Base UI's Popover portal and floating-ui context.
|
|
38
|
+
if (!mounted) {
|
|
39
|
+
return /*#__PURE__*/ _jsx(Button, {
|
|
40
|
+
buttonStyle: "tab",
|
|
41
|
+
children: bellIcon
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return /*#__PURE__*/ _jsxs(Popover.Root, {
|
|
45
|
+
children: [
|
|
46
|
+
/*#__PURE__*/ _jsx(Popover.Trigger, {
|
|
47
|
+
render: /*#__PURE__*/ _jsx(Button, {
|
|
48
|
+
buttonStyle: "tab"
|
|
49
|
+
}),
|
|
50
|
+
children: bellIcon
|
|
51
|
+
}),
|
|
52
|
+
/*#__PURE__*/ _jsx(Popover.Portal, {
|
|
53
|
+
children: /*#__PURE__*/ _jsx(Popover.Positioner, {
|
|
54
|
+
align: "start",
|
|
55
|
+
sideOffset: 8,
|
|
56
|
+
children: /*#__PURE__*/ _jsxs(Popover.Popup, {
|
|
57
|
+
className: styles.popoverPopup,
|
|
58
|
+
children: [
|
|
59
|
+
/*#__PURE__*/ _jsx(Popover.Arrow, {
|
|
60
|
+
className: styles.popoverArrow,
|
|
61
|
+
children: /*#__PURE__*/ _jsx("svg", {
|
|
62
|
+
fill: "none",
|
|
63
|
+
height: "10",
|
|
64
|
+
viewBox: "0 0 20 10",
|
|
65
|
+
width: "20",
|
|
66
|
+
children: /*#__PURE__*/ _jsx("path", {
|
|
67
|
+
d: "M9.66437 2.60207L4.80758 6.97318C4.07308 7.63423 3.11989 8 2.13172 8H0V10H20V8H18.5349C17.5468 8 16.5936 7.63423 15.8591 6.97318L11.0023 2.60207C10.622 2.2598 10.0447 2.25979 9.66437 2.60207Z"
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
}),
|
|
71
|
+
/*#__PURE__*/ _jsxs(Popover.Viewport, {
|
|
72
|
+
className: styles.popoverViewport,
|
|
73
|
+
children: [
|
|
74
|
+
/*#__PURE__*/ _jsx(Collapsible.Root, {
|
|
75
|
+
children: /*#__PURE__*/ _jsxs("div", {
|
|
76
|
+
className: styles.panelTop,
|
|
77
|
+
children: [
|
|
78
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
79
|
+
className: styles.header,
|
|
80
|
+
children: [
|
|
81
|
+
/*#__PURE__*/ _jsx("h3", {
|
|
82
|
+
className: styles.title,
|
|
83
|
+
children: "Notifications"
|
|
84
|
+
}),
|
|
85
|
+
/*#__PURE__*/ _jsx(Collapsible.Trigger, {
|
|
86
|
+
"aria-label": "Notification settings",
|
|
87
|
+
className: styles.headerAction,
|
|
88
|
+
type: "button",
|
|
89
|
+
children: /*#__PURE__*/ _jsx(IconAdjustments, {
|
|
90
|
+
size: 18,
|
|
91
|
+
strokeWidth: 1.5
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
]
|
|
95
|
+
}),
|
|
96
|
+
/*#__PURE__*/ _jsx(Collapsible.Panel, {
|
|
97
|
+
className: styles.prefsPanel,
|
|
98
|
+
children: /*#__PURE__*/ _jsxs("label", {
|
|
99
|
+
className: styles.prefRow,
|
|
100
|
+
children: [
|
|
101
|
+
/*#__PURE__*/ _jsx("input", {
|
|
102
|
+
checked: prefs?.emailEnabled ?? true,
|
|
103
|
+
onChange: ()=>togglePref('emailEnabled'),
|
|
104
|
+
type: "checkbox"
|
|
105
|
+
}),
|
|
106
|
+
"Email notifications"
|
|
107
|
+
]
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
]
|
|
111
|
+
})
|
|
112
|
+
}),
|
|
113
|
+
/*#__PURE__*/ _jsxs("div", {
|
|
114
|
+
className: styles.sections,
|
|
115
|
+
children: [
|
|
116
|
+
state.unread.length === 0 && !state.isReadLoaded && /*#__PURE__*/ _jsx("p", {
|
|
117
|
+
className: styles.empty,
|
|
118
|
+
children: "No notifications"
|
|
119
|
+
}),
|
|
120
|
+
state.unread.map((n)=>/*#__PURE__*/ _jsx(NotificationItem, {
|
|
121
|
+
apiRoute: apiRoute,
|
|
122
|
+
notification: n,
|
|
123
|
+
onDelete: deleteNotification,
|
|
124
|
+
onMarkRead: markRead,
|
|
125
|
+
onUnsubscribe: unsubscribe
|
|
126
|
+
}, n.id)),
|
|
127
|
+
!state.isReadLoaded && state.hasMore && /*#__PURE__*/ _jsx("button", {
|
|
128
|
+
className: styles.showOlder,
|
|
129
|
+
onClick: loadMore,
|
|
130
|
+
type: "button",
|
|
131
|
+
children: "Show older"
|
|
132
|
+
}),
|
|
133
|
+
state.isReadLoaded && state.read.length === 0 && state.unread.length === 0 && /*#__PURE__*/ _jsx("p", {
|
|
134
|
+
className: styles.empty,
|
|
135
|
+
children: "No notifications"
|
|
136
|
+
}),
|
|
137
|
+
state.read.map((n)=>/*#__PURE__*/ _jsx(NotificationItem, {
|
|
138
|
+
apiRoute: apiRoute,
|
|
139
|
+
notification: n,
|
|
140
|
+
onDelete: deleteNotification,
|
|
141
|
+
onMarkRead: markRead,
|
|
142
|
+
onUnsubscribe: unsubscribe
|
|
143
|
+
}, n.id)),
|
|
144
|
+
state.isReadLoaded && state.hasMoreRead && /*#__PURE__*/ _jsx("button", {
|
|
145
|
+
className: styles.showOlder,
|
|
146
|
+
disabled: isLoadingRead,
|
|
147
|
+
onClick: loadMore,
|
|
148
|
+
type: "button",
|
|
149
|
+
children: isLoadingRead ? 'Loading...' : 'Show more'
|
|
150
|
+
})
|
|
151
|
+
]
|
|
152
|
+
})
|
|
153
|
+
]
|
|
154
|
+
})
|
|
155
|
+
]
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
]
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Hooks
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
/** Polls unread notifications with `since`-based diffing. */ function useUnreadPolling(apiRoute, pollInterval, state, dispatch, enabled) {
|
|
166
|
+
const timestampRef = useRef(state.pollTimestamp);
|
|
167
|
+
timestampRef.current = state.pollTimestamp;
|
|
168
|
+
const poll = useCallback(async ()=>{
|
|
169
|
+
try {
|
|
170
|
+
const since = timestampRef.current ?? undefined;
|
|
171
|
+
const { docs, timestamp, hasMore } = await ENDPOINTS.unread.call(apiRoute, {
|
|
172
|
+
since
|
|
173
|
+
});
|
|
174
|
+
if (!timestampRef.current) {
|
|
175
|
+
dispatch({
|
|
176
|
+
type: 'SET_UNREAD',
|
|
177
|
+
docs,
|
|
178
|
+
timestamp,
|
|
179
|
+
hasMore: hasMore ?? false
|
|
180
|
+
});
|
|
181
|
+
} else {
|
|
182
|
+
dispatch({
|
|
183
|
+
type: 'PREPEND_UNREAD',
|
|
184
|
+
docs,
|
|
185
|
+
timestamp
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
} catch {
|
|
189
|
+
// Poll will retry on next interval
|
|
190
|
+
}
|
|
191
|
+
}, [
|
|
192
|
+
apiRoute,
|
|
193
|
+
dispatch
|
|
194
|
+
]);
|
|
195
|
+
useEffect(()=>{
|
|
196
|
+
if (!enabled) return;
|
|
197
|
+
let interval = null;
|
|
198
|
+
const start = ()=>{
|
|
199
|
+
poll();
|
|
200
|
+
interval = setInterval(poll, pollInterval * 1000);
|
|
201
|
+
};
|
|
202
|
+
const stop = ()=>{
|
|
203
|
+
if (interval) clearInterval(interval);
|
|
204
|
+
interval = null;
|
|
205
|
+
};
|
|
206
|
+
const onVisibilityChange = ()=>{
|
|
207
|
+
if (document.hidden) stop();
|
|
208
|
+
else start();
|
|
209
|
+
};
|
|
210
|
+
start();
|
|
211
|
+
document.addEventListener('visibilitychange', onVisibilityChange);
|
|
212
|
+
return ()=>{
|
|
213
|
+
stop();
|
|
214
|
+
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
215
|
+
};
|
|
216
|
+
}, [
|
|
217
|
+
poll,
|
|
218
|
+
pollInterval,
|
|
219
|
+
enabled
|
|
220
|
+
]);
|
|
221
|
+
}
|
|
222
|
+
/** On-demand paginated loading of read notifications. */ function useReadNotifications(apiRoute, state, dispatch) {
|
|
223
|
+
const [isLoadingRead, setIsLoadingRead] = useState(false);
|
|
224
|
+
const nextPage = state.readPage + 1;
|
|
225
|
+
const loadMore = useCallback(async ()=>{
|
|
226
|
+
setIsLoadingRead(true);
|
|
227
|
+
try {
|
|
228
|
+
const { docs, hasNextPage } = await ENDPOINTS.read.call(apiRoute, {
|
|
229
|
+
page: nextPage,
|
|
230
|
+
limit: 10
|
|
231
|
+
});
|
|
232
|
+
dispatch({
|
|
233
|
+
type: 'APPEND_READ',
|
|
234
|
+
docs,
|
|
235
|
+
hasNextPage
|
|
236
|
+
});
|
|
237
|
+
} finally{
|
|
238
|
+
setIsLoadingRead(false);
|
|
239
|
+
}
|
|
240
|
+
}, [
|
|
241
|
+
apiRoute,
|
|
242
|
+
nextPage,
|
|
243
|
+
dispatch
|
|
244
|
+
]);
|
|
245
|
+
return {
|
|
246
|
+
loadMore,
|
|
247
|
+
isLoadingRead
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
/** Optimistic mutations dispatched to the reducer. */ function useNotificationActions(apiRoute, dispatch) {
|
|
251
|
+
const markRead = useCallback(async (id)=>{
|
|
252
|
+
dispatch({
|
|
253
|
+
type: 'MARK_READ',
|
|
254
|
+
id,
|
|
255
|
+
readAt: new Date().toISOString()
|
|
256
|
+
});
|
|
257
|
+
await ENDPOINTS.markRead.call(apiRoute, {
|
|
258
|
+
id
|
|
259
|
+
});
|
|
260
|
+
}, [
|
|
261
|
+
apiRoute,
|
|
262
|
+
dispatch
|
|
263
|
+
]);
|
|
264
|
+
const markAllRead = useCallback(async ()=>{
|
|
265
|
+
dispatch({
|
|
266
|
+
type: 'MARK_ALL_READ'
|
|
267
|
+
});
|
|
268
|
+
await ENDPOINTS.markAllRead.call(apiRoute);
|
|
269
|
+
}, [
|
|
270
|
+
apiRoute,
|
|
271
|
+
dispatch
|
|
272
|
+
]);
|
|
273
|
+
const deleteNotification = useCallback(async (id)=>{
|
|
274
|
+
dispatch({
|
|
275
|
+
type: 'DELETE_NOTIFICATION',
|
|
276
|
+
id
|
|
277
|
+
});
|
|
278
|
+
await ENDPOINTS.deleteNotification.call(apiRoute, {
|
|
279
|
+
id
|
|
280
|
+
});
|
|
281
|
+
}, [
|
|
282
|
+
apiRoute,
|
|
283
|
+
dispatch
|
|
284
|
+
]);
|
|
285
|
+
return {
|
|
286
|
+
markRead,
|
|
287
|
+
markAllRead,
|
|
288
|
+
deleteNotification
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/** Reads and toggles user-level notification preferences. */ function useNotificationPreferences(apiRoute, user) {
|
|
292
|
+
const prefs = user?.notificationPreferences;
|
|
293
|
+
const togglePref = useCallback(async (field)=>{
|
|
294
|
+
if (!user) return;
|
|
295
|
+
const current = prefs?.[field] ?? true;
|
|
296
|
+
await ENDPOINTS.updatePreferences.call(apiRoute, {
|
|
297
|
+
[field]: !current
|
|
298
|
+
});
|
|
299
|
+
}, [
|
|
300
|
+
apiRoute,
|
|
301
|
+
user,
|
|
302
|
+
prefs
|
|
303
|
+
]);
|
|
304
|
+
const unsubscribe = useCallback(async (ref)=>{
|
|
305
|
+
await ENDPOINTS.unsubscribe.call(apiRoute, {
|
|
306
|
+
documentReference: toDocumentReference(ref)
|
|
307
|
+
});
|
|
308
|
+
}, [
|
|
309
|
+
apiRoute
|
|
310
|
+
]);
|
|
311
|
+
return {
|
|
312
|
+
prefs,
|
|
313
|
+
togglePref,
|
|
314
|
+
unsubscribe
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
//# sourceMappingURL=NotificationBell.js.map
|