strapi-plugin-notifier 1.0.3 → 1.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/CHANGELOG.md ADDED
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ ## [1.1.0] — 2026-06-11
4
+
5
+ ### Added
6
+ - **Batch sending** — `notifier.sendBatch(items[])` sends multiple notifications in one call; settings fetched once and opt-out/merge applied per item.
7
+ - **User opt-out** — new `notification-preference` content-type (`notifier_preferences` table). Each admin user can set `globalOptOut: true` to suppress all notifications, or `mutedTypes: ['info', ...]` to mute by type. Opt-out is applied at query time, covering broadcast and role-targeted notifications.
8
+ - New routes: `GET /notifier/preferences/me`, `PUT /notifier/preferences/me`
9
+ - **Notification merging** — when `merge.enabled` is `true` in settings, sending a notification whose key fields match an existing one (within `windowMinutes`) updates that notification's `mergeCount` instead of creating a new row.
10
+ - New notification fields: `mergeKey` (string), `mergeCount` (integer, default 1)
11
+ - New settings block: `merge.{ enabled, windowMinutes, keyFields, countBadge, rewriteMessage }`
12
+ - Inbox shows a coloured `×N` count badge when `countBadge` is on
13
+ - Merge settings editable at runtime via **Settings → Notifier → Merge**
14
+ - **Unit tests** — 61 tests covering `mergeWithDefaults`, preference service (isOptedOut, get, upsert), notification service (opt-out filtering, findMergeCandidate, mergeInto, createMany), and notifier service (send, sendBatch, merge paths, opt-out, convenience wrappers).
15
+
16
+ ### Migration notes
17
+ - A new `notifier_preferences` table is created automatically on next Strapi start — no manual migration needed.
18
+ - Two new columns (`mergeKey`, `mergeCount`) are added to the existing `notifier_notifications` table — existing rows are unaffected (columns are nullable / have defaults).
19
+ - Existing stored settings in the plugin store are unaffected; the new `merge` config block defaults to `enabled: false`.
20
+
21
+ ---
22
+
23
+ ## [1.0.3] — 2026-06-11
24
+
25
+ ### Fixed
26
+ - Settings link used `React.lazy()` wrapper — Strapi's `createSettingSection` validates `typeof Component === 'function'`; `lazy()` returns an object and fails that check. Changed to plain `() => import(...)`.
27
+ - `@strapi/admin` moved to `peerDependencies` so `pack-up` marks it external; previously `useFetchClient` was bundled into the plugin chunk, causing a blank admin panel when using a linked copy of the plugin.
28
+ - Admin route index exported an array instead of an object, preventing route registration.
29
+
30
+ ---
31
+
32
+ ## [1.0.2] — 2026-06-10
33
+
34
+ ### Added
35
+ - Initial public release.
36
+ - Live bell icon, notification inbox, targeting (broadcast / role / user), configurable retention, delivery settings, UI accent theme, and Settings panel.
package/README.md CHANGED
@@ -7,7 +7,10 @@ A first-class notification system for Strapi v5 admin panels. Adds a live bell i
7
7
  - **Live bell icon** — badge count updated by polling the server (interval configurable)
8
8
  - **Notification inbox** — filter by type, mark as read, dismiss, load more, clear all
9
9
  - **Targeting** — broadcast to all admins, or target by user ID or role code
10
- - **Configurable** — poll interval, page size, retention policy, and UI accent colours
10
+ - **Batch sending** — send multiple notifications in one call, settings fetched once
11
+ - **Merge** — collapse repeated notifications into one item with a count badge (fully configurable)
12
+ - **User opt-out** — each admin user can mute individual notification types or opt out entirely
13
+ - **Configurable** — poll interval, page size, retention policy, UI accent colours, and merge rules
11
14
  - **Settings panel** — runtime configuration via Settings → Notifier (persisted in Strapi plugin store)
12
15
  - **Retention cron** — daily cleanup at 3 AM, respects maxDays and maxPerUser limits
13
16
  - **Strapi v5 only**
@@ -51,6 +54,13 @@ export default {
51
54
  pollIntervalMs: 30_000, // how often the Bell polls (ms)
52
55
  pageSize: 20, // notifications per page in inbox
53
56
  },
57
+ merge: {
58
+ enabled: false, // enable notification merging
59
+ windowMinutes: 60, // look back this many minutes for a merge candidate
60
+ keyFields: ['title', 'type'], // fields that must match to merge
61
+ countBadge: true, // show "×N" badge on merged items in the inbox
62
+ rewriteMessage: false, // rewrite the message to "N× title" when merging
63
+ },
54
64
  ui: {
55
65
  theme: {
56
66
  accent: {
@@ -102,30 +112,125 @@ notifier.send({
102
112
  });
103
113
  ```
104
114
 
115
+ ### Batch sending
116
+
117
+ Send multiple notifications in one call. Settings (including merge config) are fetched once and applied to all items — more efficient than looping over `send()`.
118
+
119
+ ```typescript
120
+ await notifier.sendBatch([
121
+ { title: 'New job application', type: 'info', to: { role: 'strapi-editor' } },
122
+ { title: 'Export ready', type: 'success', to: { userId: 42 } },
123
+ { title: 'Low disk space', type: 'warning' }, // broadcast
124
+ ]);
125
+ ```
126
+
127
+ Opt-out and merge rules are applied per item within the batch.
128
+
105
129
  ### Notification options
106
130
 
107
- | Field | Type | Required | Default |
108
- |-----------|-------------------------------------------|----------|-------------|
109
- | `title` | `string` | Yes | — |
110
- | `message` | `string` | No | — |
111
- | `type` | `'info' \| 'success' \| 'warning' \| 'error'` | No | `'info'` |
112
- | `url` | `string` | No | — |
131
+ | Field | Type | Required | Default |
132
+ |-----------|-----------------------------------------------|----------|-----------|
133
+ | `title` | `string` | Yes | — |
134
+ | `message` | `string` | No | — |
135
+ | `type` | `'info' \| 'success' \| 'warning' \| 'error'` | No | `'info'` |
136
+ | `url` | `string` | No | — |
137
+ | `to` | `{ userId?: number; role?: string }` | No | broadcast |
138
+
139
+ ## Notification merging
140
+
141
+ When `merge.enabled` is `true`, sending a notification whose key fields match an existing notification (created within `windowMinutes`) updates that notification instead of creating a new one.
142
+
143
+ **Example — a form that can be submitted many times:**
144
+
145
+ ```typescript
146
+ // config/plugins.ts
147
+ merge: { enabled: true, windowMinutes: 60, keyFields: ['title', 'type'], countBadge: true }
148
+
149
+ // In your lifecycle / service:
150
+ notifier.toRole('strapi-editor', {
151
+ title: 'New job application',
152
+ type: 'info',
153
+ });
154
+ ```
155
+
156
+ After five submissions the inbox shows a single item: **New job application ×5**.
157
+
158
+ **Merge key fields** (`title`, `type`, `url`) — any combination. Two notifications are considered the same if all selected fields match AND the recipient matches AND the most recent one was created within the window.
159
+
160
+ **Display options:**
161
+
162
+ | Option | Default | Effect |
163
+ |------------------|---------|--------------------------------------------------|
164
+ | `countBadge` | `true` | Shows a coloured "×N" pill next to the title |
165
+ | `rewriteMessage` | `false` | Replaces the message with `"N× <title>"` |
166
+
167
+ Both can be toggled from the **Settings → Notifier → Merge** panel at runtime.
168
+
169
+ ## User opt-out
170
+
171
+ Each admin user can control which notifications they receive. Opt-out is applied at query time so it covers broadcast and role-targeted notifications too.
172
+
173
+ ### Via the API
174
+
175
+ ```http
176
+ GET /notifier/preferences/me
177
+ PUT /notifier/preferences/me
178
+ ```
179
+
180
+ **Get current preferences:**
181
+ ```http
182
+ GET /notifier/preferences/me
183
+ → { "data": { "userId": 5, "globalOptOut": false, "mutedTypes": ["info"] } }
184
+ ```
185
+
186
+ **Mute info and success notifications:**
187
+ ```http
188
+ PUT /notifier/preferences/me
189
+ Content-Type: application/json
190
+
191
+ { "mutedTypes": ["info", "success"] }
192
+ ```
193
+
194
+ **Opt out entirely:**
195
+ ```http
196
+ PUT /notifier/preferences/me
197
+ Content-Type: application/json
198
+
199
+ { "globalOptOut": true }
200
+ ```
201
+
202
+ **Re-enable:**
203
+ ```http
204
+ PUT /notifier/preferences/me
205
+ Content-Type: application/json
206
+
207
+ { "globalOptOut": false, "mutedTypes": [] }
208
+ ```
209
+
210
+ ### Preference fields
211
+
212
+ | Field | Type | Default | Description |
213
+ |----------------|--------------------|---------|------------------------------------------------|
214
+ | `globalOptOut` | `boolean` | `false` | Suppress all notifications for this user |
215
+ | `mutedTypes` | `NotificationType[]` | `[]` | Suppress only these notification types |
113
216
 
114
217
  ## API routes
115
218
 
116
219
  All routes require `admin::isAuthenticatedAdmin`. The plugin mounts under `/notifier/`.
117
220
 
118
- | Method | Path | Description |
119
- |--------|---------------------------------|--------------------------------------|
120
- | GET | `/notifier/notifications` | List notifications (paginated) |
121
- | PUT | `/notifier/notifications/read-all` | Mark all as read |
122
- | DELETE | `/notifier/notifications` | Clear all notifications |
123
- | PUT | `/notifier/notifications/:id/read` | Mark one as read |
124
- | DELETE | `/notifier/notifications/:id` | Clear one notification |
125
- | GET | `/notifier/config` | Fetch UI config (safe for frontend) |
126
- | GET | `/notifier/settings` | Get full settings (requires permission) |
127
- | PUT | `/notifier/settings` | Update settings (requires permission) |
128
- | DELETE | `/notifier/settings` | Reset settings to defaults |
221
+ | Method | Path | Description |
222
+ |--------|------------------------------------|----------------------------------------|
223
+ | GET | `/notifier/notifications` | List notifications (paginated) |
224
+ | PUT | `/notifier/notifications/read-all` | Mark all as read |
225
+ | DELETE | `/notifier/notifications` | Clear all notifications |
226
+ | PUT | `/notifier/notifications/:id/read` | Mark one as read |
227
+ | DELETE | `/notifier/notifications/:id` | Clear one notification |
228
+ | GET | `/notifier/config` | Fetch UI config (safe for frontend) |
229
+ | GET | `/notifier/settings` | Get full settings |
230
+ | PUT | `/notifier/settings` | Update settings |
231
+ | DELETE | `/notifier/settings` | Reset settings to defaults |
232
+ | GET | `/notifier/preferences/me` | Get current user's notification prefs |
233
+ | PUT | `/notifier/preferences/me` | Update current user's notification prefs |
129
234
 
130
235
  Query parameters for `GET /notifier/notifications`:
131
236
 
@@ -141,9 +246,14 @@ Two permissions are registered under the **Notifier** plugin section in **Settin
141
246
  - `plugin::notifier.settings.read` — view the settings panel
142
247
  - `plugin::notifier.settings.update` — save or reset settings
143
248
 
144
- ## Content type
249
+ ## Content types
250
+
251
+ | UID | Collection | Description |
252
+ |------------------------------------------|-----------------------------|--------------------------------------|
253
+ | `plugin::notifier.notification` | `notifier_notifications` | Notification records |
254
+ | `plugin::notifier.notification-preference` | `notifier_preferences` | Per-user opt-out preferences |
145
255
 
146
- Notifications are stored in `plugin::notifier.notification` (collection: `notifier_notifications`). The content type is hidden from Content Manager and Content-Type Builder by default.
256
+ Both are hidden from Content Manager and Content-Type Builder by default.
147
257
 
148
258
  ## License
149
259
 
@@ -4,7 +4,7 @@ const jsxRuntime = require("react/jsx-runtime");
4
4
  const react = require("react");
5
5
  const designSystem = require("@strapi/design-system");
6
6
  const strapiAdmin = require("@strapi/admin/strapi-admin");
7
- const index = require("./index-DrwLcZBZ.js");
7
+ const index = require("./index-CKh_iBP2.js");
8
8
  const icons = require("@strapi/icons");
9
9
  const toStoreItem = (n) => ({
10
10
  id: String(n.id),
@@ -204,6 +204,24 @@ function NotificationItem({
204
204
  children: notification.title
205
205
  }
206
206
  ),
207
+ notification.mergeCount != null && notification.mergeCount > 1 && /* @__PURE__ */ jsxRuntime.jsxs(
208
+ designSystem.Typography,
209
+ {
210
+ variant: "pi",
211
+ style: {
212
+ background: accent,
213
+ color: "#fff",
214
+ borderRadius: "10px",
215
+ padding: "0 6px",
216
+ flexShrink: 0,
217
+ fontWeight: 600
218
+ },
219
+ children: [
220
+ "×",
221
+ notification.mergeCount
222
+ ]
223
+ }
224
+ ),
207
225
  notification.url && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral500", children: "↗" })
208
226
  ] }),
209
227
  notification.message && /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral600", style: { marginTop: 2 }, children: notification.message }),
@@ -2,7 +2,7 @@ import { jsxs, jsx } from "react/jsx-runtime";
2
2
  import { useState, useRef, useCallback, useEffect } from "react";
3
3
  import { Box, Flex, Typography, Button, IconButton, Loader } from "@strapi/design-system";
4
4
  import { useFetchClient } from "@strapi/admin/strapi-admin";
5
- import { g as getConfig, s as setNotifications, a as getNotifications, m as markAsRead, b as markAllAsRead, c as clearNotification, d as clearAll, e as subscribeToNotifications, u as usePluginConfig } from "./index-7hrPEwa_.mjs";
5
+ import { g as getConfig, s as setNotifications, a as getNotifications, m as markAsRead, b as markAllAsRead, c as clearNotification, d as clearAll, e as subscribeToNotifications, u as usePluginConfig } from "./index-BgPnE501.mjs";
6
6
  import { Cross } from "@strapi/icons";
7
7
  const toStoreItem = (n) => ({
8
8
  id: String(n.id),
@@ -202,6 +202,24 @@ function NotificationItem({
202
202
  children: notification.title
203
203
  }
204
204
  ),
205
+ notification.mergeCount != null && notification.mergeCount > 1 && /* @__PURE__ */ jsxs(
206
+ Typography,
207
+ {
208
+ variant: "pi",
209
+ style: {
210
+ background: accent,
211
+ color: "#fff",
212
+ borderRadius: "10px",
213
+ padding: "0 6px",
214
+ flexShrink: 0,
215
+ fontWeight: 600
216
+ },
217
+ children: [
218
+ "×",
219
+ notification.mergeCount
220
+ ]
221
+ }
222
+ ),
205
223
  notification.url && /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral500", children: "↗" })
206
224
  ] }),
207
225
  notification.message && /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", style: { marginTop: 2 }, children: notification.message }),
@@ -1,12 +1,24 @@
1
- import { jsxs, jsx } from "react/jsx-runtime";
1
+ import { jsxs, jsx, Fragment } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from "react";
3
- import { Box, Flex, Typography, Button, NumberInput, Divider, TextInput } from "@strapi/design-system";
3
+ import { Box, Flex, Typography, Button, NumberInput, Toggle, Divider, Checkbox, TextInput } from "@strapi/design-system";
4
4
  import { useFetchClient } from "@strapi/admin/strapi-admin";
5
5
  const DEFAULT = {
6
6
  retention: { maxDays: 90, maxPerUser: 500 },
7
7
  delivery: { pollIntervalMs: 3e4, pageSize: 20 },
8
+ merge: {
9
+ enabled: false,
10
+ windowMinutes: 60,
11
+ keyFields: ["title", "type"],
12
+ countBadge: true,
13
+ rewriteMessage: false
14
+ },
8
15
  ui: { theme: { accent: { info: "#4945ff", success: "#5cb85c", warning: "#f0ad4e", error: "#ee5e52" } } }
9
16
  };
17
+ const MERGE_KEY_OPTIONS = [
18
+ { value: "title", label: "Title" },
19
+ { value: "type", label: "Type" },
20
+ { value: "url", label: "URL" }
21
+ ];
10
22
  function SettingsPage() {
11
23
  const { get, put, del } = useFetchClient();
12
24
  const [settings, setSettings] = useState(DEFAULT);
@@ -51,6 +63,13 @@ function SettingsPage() {
51
63
  return next;
52
64
  });
53
65
  };
66
+ const toggleMergeKeyField = (field) => {
67
+ setSettings((prev) => {
68
+ const current = prev.merge.keyFields;
69
+ const next = current.includes(field) ? current.filter((f) => f !== field) : [...current, field];
70
+ return { ...prev, merge: { ...prev.merge, keyFields: next } };
71
+ });
72
+ };
54
73
  return /* @__PURE__ */ jsxs(Box, { padding: 8, children: [
55
74
  /* @__PURE__ */ jsxs(Flex, { justifyContent: "space-between", alignItems: "center", paddingBottom: 6, children: [
56
75
  /* @__PURE__ */ jsx(Typography, { variant: "alpha", children: "Notifier Settings" }),
@@ -110,6 +129,79 @@ function SettingsPage() {
110
129
  )
111
130
  ] })
112
131
  ] }),
132
+ /* @__PURE__ */ jsxs(Box, { background: "neutral0", padding: 6, hasRadius: true, marginTop: 4, children: [
133
+ /* @__PURE__ */ jsxs(Flex, { justifyContent: "space-between", alignItems: "center", paddingBottom: 4, children: [
134
+ /* @__PURE__ */ jsxs(Box, { children: [
135
+ /* @__PURE__ */ jsx(Typography, { variant: "delta", as: "h2", children: "Merge" }),
136
+ /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", children: "Collapse repeated notifications into one, with a count badge." })
137
+ ] }),
138
+ /* @__PURE__ */ jsx(
139
+ Toggle,
140
+ {
141
+ label: "Enable merging",
142
+ offLabel: "Off",
143
+ onLabel: "On",
144
+ checked: settings.merge.enabled,
145
+ onChange: (e) => set("merge.enabled", e.target.checked)
146
+ }
147
+ )
148
+ ] }),
149
+ settings.merge.enabled && /* @__PURE__ */ jsxs(Fragment, { children: [
150
+ /* @__PURE__ */ jsx(Divider, {}),
151
+ /* @__PURE__ */ jsxs(Box, { paddingTop: 4, children: [
152
+ /* @__PURE__ */ jsx(Flex, { gap: 4, wrap: "wrap", paddingBottom: 4, children: /* @__PURE__ */ jsx(
153
+ NumberInput,
154
+ {
155
+ label: "Window (minutes)",
156
+ hint: "How far back to look for a notification to merge into.",
157
+ value: settings.merge.windowMinutes,
158
+ onValueChange: (v) => set("merge.windowMinutes", v),
159
+ style: { flex: 1 }
160
+ }
161
+ ) }),
162
+ /* @__PURE__ */ jsx(Typography, { variant: "sigma", paddingBottom: 2, children: "Merge key fields" }),
163
+ /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", paddingBottom: 3, children: "Two notifications are considered the same if all selected fields match." }),
164
+ /* @__PURE__ */ jsx(Flex, { gap: 4, wrap: "wrap", paddingBottom: 4, children: MERGE_KEY_OPTIONS.map(({ value, label }) => /* @__PURE__ */ jsx(
165
+ Checkbox,
166
+ {
167
+ checked: settings.merge.keyFields.includes(value),
168
+ onCheckedChange: () => toggleMergeKeyField(value),
169
+ children: label
170
+ },
171
+ value
172
+ )) }),
173
+ /* @__PURE__ */ jsx(Typography, { variant: "sigma", paddingBottom: 2, children: "Display" }),
174
+ /* @__PURE__ */ jsxs(Flex, { gap: 6, wrap: "wrap", children: [
175
+ /* @__PURE__ */ jsxs(Flex, { gap: 2, alignItems: "center", children: [
176
+ /* @__PURE__ */ jsx(
177
+ Toggle,
178
+ {
179
+ label: "Count badge",
180
+ offLabel: "Off",
181
+ onLabel: "On",
182
+ checked: settings.merge.countBadge,
183
+ onChange: (e) => set("merge.countBadge", e.target.checked)
184
+ }
185
+ ),
186
+ /* @__PURE__ */ jsx(Box, { paddingLeft: 2, children: /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", children: "Show ×N badge on merged items" }) })
187
+ ] }),
188
+ /* @__PURE__ */ jsxs(Flex, { gap: 2, alignItems: "center", children: [
189
+ /* @__PURE__ */ jsx(
190
+ Toggle,
191
+ {
192
+ label: "Rewrite message",
193
+ offLabel: "Off",
194
+ onLabel: "On",
195
+ checked: settings.merge.rewriteMessage,
196
+ onChange: (e) => set("merge.rewriteMessage", e.target.checked)
197
+ }
198
+ ),
199
+ /* @__PURE__ */ jsx(Box, { paddingLeft: 2, children: /* @__PURE__ */ jsx(Typography, { variant: "pi", textColor: "neutral600", children: 'Replace message with "N× title"' }) })
200
+ ] })
201
+ ] })
202
+ ] })
203
+ ] })
204
+ ] }),
113
205
  /* @__PURE__ */ jsxs(Box, { background: "neutral0", padding: 6, hasRadius: true, marginTop: 4, children: [
114
206
  /* @__PURE__ */ jsx(Typography, { variant: "delta", paddingBottom: 4, as: "h2", children: "Theme" }),
115
207
  /* @__PURE__ */ jsx(Divider, {}),
@@ -7,8 +7,20 @@ const strapiAdmin = require("@strapi/admin/strapi-admin");
7
7
  const DEFAULT = {
8
8
  retention: { maxDays: 90, maxPerUser: 500 },
9
9
  delivery: { pollIntervalMs: 3e4, pageSize: 20 },
10
+ merge: {
11
+ enabled: false,
12
+ windowMinutes: 60,
13
+ keyFields: ["title", "type"],
14
+ countBadge: true,
15
+ rewriteMessage: false
16
+ },
10
17
  ui: { theme: { accent: { info: "#4945ff", success: "#5cb85c", warning: "#f0ad4e", error: "#ee5e52" } } }
11
18
  };
19
+ const MERGE_KEY_OPTIONS = [
20
+ { value: "title", label: "Title" },
21
+ { value: "type", label: "Type" },
22
+ { value: "url", label: "URL" }
23
+ ];
12
24
  function SettingsPage() {
13
25
  const { get, put, del } = strapiAdmin.useFetchClient();
14
26
  const [settings, setSettings] = react.useState(DEFAULT);
@@ -53,6 +65,13 @@ function SettingsPage() {
53
65
  return next;
54
66
  });
55
67
  };
68
+ const toggleMergeKeyField = (field) => {
69
+ setSettings((prev) => {
70
+ const current = prev.merge.keyFields;
71
+ const next = current.includes(field) ? current.filter((f) => f !== field) : [...current, field];
72
+ return { ...prev, merge: { ...prev.merge, keyFields: next } };
73
+ });
74
+ };
56
75
  return /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { padding: 8, children: [
57
76
  /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { justifyContent: "space-between", alignItems: "center", paddingBottom: 6, children: [
58
77
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "alpha", children: "Notifier Settings" }),
@@ -112,6 +131,79 @@ function SettingsPage() {
112
131
  )
113
132
  ] })
114
133
  ] }),
134
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { background: "neutral0", padding: 6, hasRadius: true, marginTop: 4, children: [
135
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { justifyContent: "space-between", alignItems: "center", paddingBottom: 4, children: [
136
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { children: [
137
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "delta", as: "h2", children: "Merge" }),
138
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral600", children: "Collapse repeated notifications into one, with a count badge." })
139
+ ] }),
140
+ /* @__PURE__ */ jsxRuntime.jsx(
141
+ designSystem.Toggle,
142
+ {
143
+ label: "Enable merging",
144
+ offLabel: "Off",
145
+ onLabel: "On",
146
+ checked: settings.merge.enabled,
147
+ onChange: (e) => set("merge.enabled", e.target.checked)
148
+ }
149
+ )
150
+ ] }),
151
+ settings.merge.enabled && /* @__PURE__ */ jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [
152
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Divider, {}),
153
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { paddingTop: 4, children: [
154
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Flex, { gap: 4, wrap: "wrap", paddingBottom: 4, children: /* @__PURE__ */ jsxRuntime.jsx(
155
+ designSystem.NumberInput,
156
+ {
157
+ label: "Window (minutes)",
158
+ hint: "How far back to look for a notification to merge into.",
159
+ value: settings.merge.windowMinutes,
160
+ onValueChange: (v) => set("merge.windowMinutes", v),
161
+ style: { flex: 1 }
162
+ }
163
+ ) }),
164
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "sigma", paddingBottom: 2, children: "Merge key fields" }),
165
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral600", paddingBottom: 3, children: "Two notifications are considered the same if all selected fields match." }),
166
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Flex, { gap: 4, wrap: "wrap", paddingBottom: 4, children: MERGE_KEY_OPTIONS.map(({ value, label }) => /* @__PURE__ */ jsxRuntime.jsx(
167
+ designSystem.Checkbox,
168
+ {
169
+ checked: settings.merge.keyFields.includes(value),
170
+ onCheckedChange: () => toggleMergeKeyField(value),
171
+ children: label
172
+ },
173
+ value
174
+ )) }),
175
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "sigma", paddingBottom: 2, children: "Display" }),
176
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { gap: 6, wrap: "wrap", children: [
177
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { gap: 2, alignItems: "center", children: [
178
+ /* @__PURE__ */ jsxRuntime.jsx(
179
+ designSystem.Toggle,
180
+ {
181
+ label: "Count badge",
182
+ offLabel: "Off",
183
+ onLabel: "On",
184
+ checked: settings.merge.countBadge,
185
+ onChange: (e) => set("merge.countBadge", e.target.checked)
186
+ }
187
+ ),
188
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { paddingLeft: 2, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral600", children: "Show ×N badge on merged items" }) })
189
+ ] }),
190
+ /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Flex, { gap: 2, alignItems: "center", children: [
191
+ /* @__PURE__ */ jsxRuntime.jsx(
192
+ designSystem.Toggle,
193
+ {
194
+ label: "Rewrite message",
195
+ offLabel: "Off",
196
+ onLabel: "On",
197
+ checked: settings.merge.rewriteMessage,
198
+ onChange: (e) => set("merge.rewriteMessage", e.target.checked)
199
+ }
200
+ ),
201
+ /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { paddingLeft: 2, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "pi", textColor: "neutral600", children: 'Replace message with "N× title"' }) })
202
+ ] })
203
+ ] })
204
+ ] })
205
+ ] })
206
+ ] }),
115
207
  /* @__PURE__ */ jsxRuntime.jsxs(designSystem.Box, { background: "neutral0", padding: 6, hasRadius: true, marginTop: 4, children: [
116
208
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "delta", paddingBottom: 4, as: "h2", children: "Theme" }),
117
209
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Divider, {}),
@@ -176,7 +176,7 @@ const index = {
176
176
  defaultMessage: "Notifications"
177
177
  },
178
178
  permissions: [],
179
- Component: () => import("./Index-DOQrGurB.mjs")
179
+ Component: () => import("./Index-_PTtaZsz.mjs")
180
180
  });
181
181
  app.createSettingSection(
182
182
  {
@@ -194,7 +194,7 @@ const index = {
194
194
  },
195
195
  id: `${pluginId}.settings`,
196
196
  to: `/settings/${pluginId}`,
197
- Component: () => import("./SettingsPage-CRsuB4cw.mjs"),
197
+ Component: () => import("./SettingsPage-CuTIslG0.mjs"),
198
198
  permissions: [
199
199
  { action: `plugin::${pluginId}.settings.read`, subject: null }
200
200
  ]
@@ -177,7 +177,7 @@ const index = {
177
177
  defaultMessage: "Notifications"
178
178
  },
179
179
  permissions: [],
180
- Component: () => Promise.resolve().then(() => require("./Index-C5mgbISF.js"))
180
+ Component: () => Promise.resolve().then(() => require("./Index-DVk2enQ2.js"))
181
181
  });
182
182
  app.createSettingSection(
183
183
  {
@@ -195,7 +195,7 @@ const index = {
195
195
  },
196
196
  id: `${pluginId}.settings`,
197
197
  to: `/settings/${pluginId}`,
198
- Component: () => Promise.resolve().then(() => require("./SettingsPage-Cft7agRa.js")),
198
+ Component: () => Promise.resolve().then(() => require("./SettingsPage-DYxNBA3h.js")),
199
199
  permissions: [
200
200
  { action: `plugin::${pluginId}.settings.read`, subject: null }
201
201
  ]
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
- const index = require("../_chunks/index-DrwLcZBZ.js");
2
+ const index = require("../_chunks/index-CKh_iBP2.js");
3
3
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-7hrPEwa_.mjs";
1
+ import { i } from "../_chunks/index-BgPnE501.mjs";
2
2
  export {
3
3
  i as default
4
4
  };