@meith/notifications 0.16.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/LICENSE.md +165 -0
- package/package.json +26 -0
- package/src/deliver-push.ts +116 -0
- package/src/deliver.ts +48 -0
- package/src/index.ts +64 -0
- package/src/kinds.ts +189 -0
- package/src/mail.ts +73 -0
- package/src/push.ts +236 -0
- package/src/render.ts +275 -0
- package/src/service.ts +276 -0
- package/src/types.ts +133 -0
package/src/push.ts
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { createCipheriv, hkdfSync, randomBytes } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export const PUSH_PAYLOAD_LIMIT = 3000
|
|
4
|
+
|
|
5
|
+
export const PUSH_RECORD_SIZE = 4096
|
|
6
|
+
|
|
7
|
+
export const PUSH_REQUEST_TIMEOUT_MS = 10_000
|
|
8
|
+
|
|
9
|
+
export const PUSH_TTL_SECONDS = 86_400
|
|
10
|
+
|
|
11
|
+
const VAPID_EXPIRY_SECONDS = 12 * 60 * 60
|
|
12
|
+
|
|
13
|
+
const CURVE: EcKeyImportParams = { name: 'ECDH', namedCurve: 'P-256' }
|
|
14
|
+
|
|
15
|
+
const SIGNING: EcKeyImportParams = { name: 'ECDSA', namedCurve: 'P-256' }
|
|
16
|
+
|
|
17
|
+
export interface VapidKeyPair {
|
|
18
|
+
readonly publicKey: string
|
|
19
|
+
readonly privateKey: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface VapidDetails extends VapidKeyPair {
|
|
23
|
+
readonly subject: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface PushSubscriptionKeys {
|
|
27
|
+
readonly endpoint: string
|
|
28
|
+
readonly p256dh: string
|
|
29
|
+
readonly auth: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type PushSendOutcome = 'sent' | 'gone' | 'failed'
|
|
33
|
+
|
|
34
|
+
export interface PushSendResult {
|
|
35
|
+
readonly outcome: PushSendOutcome
|
|
36
|
+
readonly status: number | null
|
|
37
|
+
readonly error: string | null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function decode(value: string): Buffer {
|
|
41
|
+
return Buffer.from(value, 'base64url')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function encode(value: ArrayBuffer | Uint8Array): string {
|
|
45
|
+
return Buffer.from(value instanceof Uint8Array ? value : new Uint8Array(value)).toString(
|
|
46
|
+
'base64url',
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function bytes(value: Buffer): Uint8Array<ArrayBuffer> {
|
|
51
|
+
return new Uint8Array(value)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function generateVapidKeys(): Promise<VapidKeyPair> {
|
|
55
|
+
const pair = await crypto.subtle.generateKey(SIGNING, true, ['sign', 'verify'])
|
|
56
|
+
const jwk = await crypto.subtle.exportKey('jwk', pair.privateKey)
|
|
57
|
+
const raw = await crypto.subtle.exportKey('raw', pair.publicKey)
|
|
58
|
+
|
|
59
|
+
if (jwk.d === undefined) throw new Error('The generated key carries no private scalar.')
|
|
60
|
+
|
|
61
|
+
return { publicKey: encode(raw), privateKey: jwk.d }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function isVapidPublicKey(value: string): boolean {
|
|
65
|
+
const raw = decode(value)
|
|
66
|
+
return raw.length === 65 && raw[0] === 4
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function isVapidPrivateKey(value: string): boolean {
|
|
70
|
+
return decode(value).length === 32
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function jwkFor(keys: VapidKeyPair): JsonWebKey {
|
|
74
|
+
const raw = decode(keys.publicKey)
|
|
75
|
+
if (raw.length !== 65 || raw[0] !== 4) {
|
|
76
|
+
throw new Error('A VAPID public key is a 65-byte uncompressed P-256 point.')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
kty: 'EC',
|
|
81
|
+
crv: 'P-256',
|
|
82
|
+
x: raw.subarray(1, 33).toString('base64url'),
|
|
83
|
+
y: raw.subarray(33, 65).toString('base64url'),
|
|
84
|
+
d: keys.privateKey,
|
|
85
|
+
ext: true,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function pushAudience(endpoint: string): string {
|
|
90
|
+
const url = new URL(endpoint)
|
|
91
|
+
return `${url.protocol}//${url.host}`
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function vapidAuthorization(
|
|
95
|
+
vapid: VapidDetails,
|
|
96
|
+
endpoint: string,
|
|
97
|
+
now: Date = new Date(),
|
|
98
|
+
): Promise<string> {
|
|
99
|
+
const header = encode(Buffer.from(JSON.stringify({ typ: 'JWT', alg: 'ES256' })))
|
|
100
|
+
const claims = encode(
|
|
101
|
+
Buffer.from(
|
|
102
|
+
JSON.stringify({
|
|
103
|
+
aud: pushAudience(endpoint),
|
|
104
|
+
exp: Math.floor(now.getTime() / 1000) + VAPID_EXPIRY_SECONDS,
|
|
105
|
+
sub: vapid.subject,
|
|
106
|
+
}),
|
|
107
|
+
),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
const key = await crypto.subtle.importKey('jwk', jwkFor(vapid), SIGNING, false, ['sign'])
|
|
111
|
+
const signature = await crypto.subtle.sign(
|
|
112
|
+
{ name: 'ECDSA', hash: 'SHA-256' },
|
|
113
|
+
key,
|
|
114
|
+
bytes(Buffer.from(`${header}.${claims}`)),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return `vapid t=${header}.${claims}.${encode(signature)}, k=${vapid.publicKey}`
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface EncryptedPushPayload {
|
|
121
|
+
readonly body: Buffer
|
|
122
|
+
readonly salt: Buffer
|
|
123
|
+
readonly serverPublicKey: Buffer
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function encryptPushPayload(
|
|
127
|
+
subscription: Pick<PushSubscriptionKeys, 'p256dh' | 'auth'>,
|
|
128
|
+
payload: string,
|
|
129
|
+
salt: Buffer = randomBytes(16),
|
|
130
|
+
): Promise<EncryptedPushPayload> {
|
|
131
|
+
const plaintext = Buffer.from(payload, 'utf8')
|
|
132
|
+
if (plaintext.byteLength > PUSH_PAYLOAD_LIMIT) {
|
|
133
|
+
throw new Error(`A push payload may not exceed ${PUSH_PAYLOAD_LIMIT} bytes.`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const clientPublic = decode(subscription.p256dh)
|
|
137
|
+
const authSecret = decode(subscription.auth)
|
|
138
|
+
|
|
139
|
+
const client = await crypto.subtle.importKey('raw', bytes(clientPublic), CURVE, false, [])
|
|
140
|
+
const server = await crypto.subtle.generateKey(CURVE, true, ['deriveBits'])
|
|
141
|
+
const serverPublic = Buffer.from(await crypto.subtle.exportKey('raw', server.publicKey))
|
|
142
|
+
|
|
143
|
+
const shared = Buffer.from(
|
|
144
|
+
await crypto.subtle.deriveBits({ name: 'ECDH', public: client }, server.privateKey, 256),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
const keyInfo = Buffer.concat([
|
|
148
|
+
Buffer.from('WebPush: info\0', 'utf8'),
|
|
149
|
+
clientPublic,
|
|
150
|
+
serverPublic,
|
|
151
|
+
])
|
|
152
|
+
|
|
153
|
+
const ikm = Buffer.from(hkdfSync('sha256', shared, authSecret, keyInfo, 32))
|
|
154
|
+
const key = Buffer.from(
|
|
155
|
+
hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: aes128gcm\0', 'utf8'), 16),
|
|
156
|
+
)
|
|
157
|
+
const nonce = Buffer.from(
|
|
158
|
+
hkdfSync('sha256', ikm, salt, Buffer.from('Content-Encoding: nonce\0', 'utf8'), 12),
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const cipher = createCipheriv('aes-128-gcm', key, nonce)
|
|
162
|
+
const sealed = Buffer.concat([
|
|
163
|
+
cipher.update(Buffer.concat([plaintext, Buffer.from([2])])),
|
|
164
|
+
cipher.final(),
|
|
165
|
+
cipher.getAuthTag(),
|
|
166
|
+
])
|
|
167
|
+
|
|
168
|
+
const header = Buffer.alloc(5)
|
|
169
|
+
header.writeUInt32BE(PUSH_RECORD_SIZE, 0)
|
|
170
|
+
header.writeUInt8(serverPublic.length, 4)
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
body: Buffer.concat([salt, header, serverPublic, sealed]),
|
|
174
|
+
salt,
|
|
175
|
+
serverPublicKey: serverPublic,
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function pushOutcomeFor(status: number): PushSendOutcome {
|
|
180
|
+
if (status >= 200 && status < 300) return 'sent'
|
|
181
|
+
if (status === 404 || status === 410) return 'gone'
|
|
182
|
+
return 'failed'
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export async function sendWebPush(input: {
|
|
186
|
+
readonly subscription: PushSubscriptionKeys
|
|
187
|
+
readonly payload: string
|
|
188
|
+
readonly vapid: VapidDetails
|
|
189
|
+
readonly ttlSeconds?: number
|
|
190
|
+
readonly now?: Date
|
|
191
|
+
readonly fetchImpl?: typeof fetch
|
|
192
|
+
}): Promise<PushSendResult> {
|
|
193
|
+
const doFetch = input.fetchImpl ?? fetch
|
|
194
|
+
|
|
195
|
+
let encrypted: EncryptedPushPayload
|
|
196
|
+
let authorization: string
|
|
197
|
+
try {
|
|
198
|
+
encrypted = await encryptPushPayload(input.subscription, input.payload)
|
|
199
|
+
authorization = await vapidAuthorization(
|
|
200
|
+
input.vapid,
|
|
201
|
+
input.subscription.endpoint,
|
|
202
|
+
input.now ?? new Date(),
|
|
203
|
+
)
|
|
204
|
+
} catch (err) {
|
|
205
|
+
return { outcome: 'failed', status: null, error: message(err) }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const response = await doFetch(input.subscription.endpoint, {
|
|
210
|
+
method: 'POST',
|
|
211
|
+
headers: {
|
|
212
|
+
authorization,
|
|
213
|
+
'content-encoding': 'aes128gcm',
|
|
214
|
+
'content-type': 'application/octet-stream',
|
|
215
|
+
ttl: String(input.ttlSeconds ?? PUSH_TTL_SECONDS),
|
|
216
|
+
urgency: 'normal',
|
|
217
|
+
},
|
|
218
|
+
body: bytes(encrypted.body),
|
|
219
|
+
redirect: 'manual',
|
|
220
|
+
signal: AbortSignal.timeout(PUSH_REQUEST_TIMEOUT_MS),
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
const outcome = pushOutcomeFor(response.status)
|
|
224
|
+
return {
|
|
225
|
+
outcome,
|
|
226
|
+
status: response.status,
|
|
227
|
+
error: outcome === 'sent' ? null : `HTTP ${response.status}`,
|
|
228
|
+
}
|
|
229
|
+
} catch (err) {
|
|
230
|
+
return { outcome: 'failed', status: null, error: message(err) }
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function message(err: unknown): string {
|
|
235
|
+
return err instanceof Error ? err.message : String(err)
|
|
236
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { EN_CATALOG, sourceTranslator, type Translator } from '@meith/i18n'
|
|
2
|
+
|
|
3
|
+
import { notificationKind } from './kinds'
|
|
4
|
+
import type { NotificationData, NotificationRecord } from './types'
|
|
5
|
+
|
|
6
|
+
export interface NotificationView {
|
|
7
|
+
readonly id: number
|
|
8
|
+
readonly subject: string
|
|
9
|
+
readonly body: string
|
|
10
|
+
readonly href: string | null
|
|
11
|
+
readonly kind: string
|
|
12
|
+
readonly occurrences: number
|
|
13
|
+
readonly createdAt: Date
|
|
14
|
+
readonly updatedAt: Date
|
|
15
|
+
readonly isRead: boolean
|
|
16
|
+
readonly unsubscribeToken: string | null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function objects(data: NotificationData, key: string): readonly NotificationData[] {
|
|
20
|
+
const value = data[key]
|
|
21
|
+
if (!Array.isArray(value)) return []
|
|
22
|
+
return value.filter(
|
|
23
|
+
(entry): entry is NotificationData =>
|
|
24
|
+
entry !== null && typeof entry === 'object' && !Array.isArray(entry),
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function str(data: NotificationData, key: string, fallback = ''): string {
|
|
29
|
+
const value = data[key]
|
|
30
|
+
if (typeof value === 'string') return value
|
|
31
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
32
|
+
return fallback
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function num(data: NotificationData, key: string, fallback = 0): number {
|
|
36
|
+
const value = data[key]
|
|
37
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value
|
|
38
|
+
if (typeof value === 'string') {
|
|
39
|
+
const parsed = Number(value)
|
|
40
|
+
if (Number.isFinite(parsed)) return parsed
|
|
41
|
+
}
|
|
42
|
+
return fallback
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function args(data: NotificationData, key: string): Readonly<Record<string, string | number>> {
|
|
46
|
+
const raw = str(data, key)
|
|
47
|
+
if (raw === '') return {}
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
const value: unknown = JSON.parse(raw)
|
|
51
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {}
|
|
52
|
+
return Object.fromEntries(
|
|
53
|
+
Object.entries(value).filter(
|
|
54
|
+
(entry): entry is [string, string | number] =>
|
|
55
|
+
typeof entry[1] === 'string' || typeof entry[1] === 'number',
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
} catch {
|
|
59
|
+
return {}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pluginMessage(
|
|
64
|
+
data: NotificationData,
|
|
65
|
+
textKey: string,
|
|
66
|
+
argsKey: string,
|
|
67
|
+
legacyKey: string,
|
|
68
|
+
t: Translator,
|
|
69
|
+
): string {
|
|
70
|
+
const key = str(data, textKey)
|
|
71
|
+
return key === '' ? str(data, legacyKey) : t.t(key, args(data, argsKey))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function points(t: Translator, value: number): string {
|
|
75
|
+
return t.t('notification.render.points', { count: value })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const DIGEST_CADENCE_KEYS = {
|
|
79
|
+
daily: 'notification.render.digest.daily',
|
|
80
|
+
weekly: 'notification.render.digest.weekly',
|
|
81
|
+
} as const
|
|
82
|
+
|
|
83
|
+
const TEMPLATES: Readonly<
|
|
84
|
+
Record<string, (data: NotificationData, t: Translator) => { subject: string; body: string }>
|
|
85
|
+
> = {
|
|
86
|
+
'warning.received': (data, t) => {
|
|
87
|
+
const title = str(data, 'title', t.t('notification.render.warning.fallbackTitle'))
|
|
88
|
+
const reason = str(data, 'reason')
|
|
89
|
+
const total = num(data, 'totalPoints')
|
|
90
|
+
const restriction = str(data, 'restriction')
|
|
91
|
+
|
|
92
|
+
const consequence =
|
|
93
|
+
restriction === 'suspend_posting'
|
|
94
|
+
? ` ${t.t('notification.render.warning.suspendPosting')}`
|
|
95
|
+
: restriction === 'moderate_posting'
|
|
96
|
+
? ` ${t.t('notification.render.warning.moderatePosting')}`
|
|
97
|
+
: restriction === 'ban'
|
|
98
|
+
? ` ${t.t('notification.render.warning.banned')}`
|
|
99
|
+
: ''
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
subject: t.t('notification.render.warning.subject', { title }),
|
|
103
|
+
body:
|
|
104
|
+
t.t('notification.render.warning.body', {
|
|
105
|
+
points: points(t, num(data, 'points')),
|
|
106
|
+
total: points(t, total),
|
|
107
|
+
}) +
|
|
108
|
+
consequence +
|
|
109
|
+
(reason === '' ? '' : `\n\n${t.t('notification.render.warning.reason', { reason })}`),
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
'report.actioned': (data, t) => {
|
|
114
|
+
const outcome = str(data, 'outcome')
|
|
115
|
+
const label = str(data, 'targetLabel', t.t('notification.render.report.fallbackLabel'))
|
|
116
|
+
return {
|
|
117
|
+
subject:
|
|
118
|
+
outcome === 'rejected'
|
|
119
|
+
? t.t('notification.render.report.rejectedSubject')
|
|
120
|
+
: t.t('notification.render.report.actionedSubject'),
|
|
121
|
+
body:
|
|
122
|
+
outcome === 'rejected'
|
|
123
|
+
? t.t('notification.render.report.rejectedBody', { label })
|
|
124
|
+
: t.t('notification.render.report.actionedBody', { label }),
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
'subscription.reply': (data, t) => {
|
|
129
|
+
const title = str(data, 'threadTitle', t.t('notification.render.reply.fallbackTitle'))
|
|
130
|
+
const posts = num(data, 'posts', 1)
|
|
131
|
+
const author = str(data, 'lastAuthor')
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
subject: t.t('notification.render.reply.subject', { count: posts, title }),
|
|
135
|
+
body:
|
|
136
|
+
author === ''
|
|
137
|
+
? t.t('notification.render.reply.noAuthor')
|
|
138
|
+
: t.t('notification.render.reply.author', { author }),
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
'subscription.digest': (data, t) => {
|
|
143
|
+
const cadence = str(data, 'cadence') === 'weekly' ? 'weekly' : 'daily'
|
|
144
|
+
const threadCount = num(data, 'threadCount')
|
|
145
|
+
const postCount = num(data, 'postCount')
|
|
146
|
+
const more = num(data, 'more')
|
|
147
|
+
|
|
148
|
+
const lines = objects(data, 'threads').map((thread) => {
|
|
149
|
+
const posts = num(thread, 'posts', 1)
|
|
150
|
+
const author = str(thread, 'lastAuthor')
|
|
151
|
+
return t.t('notification.render.digest.entry', {
|
|
152
|
+
title: str(thread, 'title', t.t('notification.render.digest.fallbackTitle')),
|
|
153
|
+
count: posts,
|
|
154
|
+
author: author === '' ? '' : t.t('notification.render.digest.entryAuthor', { author }),
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
if (more > 0) lines.push(t.t('notification.render.digest.more', { count: more }))
|
|
159
|
+
|
|
160
|
+
return {
|
|
161
|
+
subject: t.t('notification.render.digest.subject', {
|
|
162
|
+
cadence: t.t(DIGEST_CADENCE_KEYS[cadence]),
|
|
163
|
+
posts: postCount,
|
|
164
|
+
threads: threadCount,
|
|
165
|
+
}),
|
|
166
|
+
body: lines.join('\n'),
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
'post.mentioned': (data, t) => {
|
|
171
|
+
const by = str(data, 'byUsername', t.t('notification.render.somebody'))
|
|
172
|
+
const title = str(data, 'threadTitle', t.t('notification.render.threadFallback'))
|
|
173
|
+
return {
|
|
174
|
+
subject: t.t('notification.render.mentioned.subject', { by, title }),
|
|
175
|
+
body: t.t('notification.render.mentioned.body', { by, title }),
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
|
|
179
|
+
'post.quoted': (data, t) => {
|
|
180
|
+
const by = str(data, 'byUsername', t.t('notification.render.somebody'))
|
|
181
|
+
const title = str(data, 'threadTitle', t.t('notification.render.threadFallback'))
|
|
182
|
+
return {
|
|
183
|
+
subject: t.t('notification.render.quoted.subject', { by, title }),
|
|
184
|
+
body: t.t('notification.render.quoted.body', { by, title }),
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
'pm.received': (data, t) => {
|
|
189
|
+
const from = str(data, 'fromUsername', t.t('notification.render.somebody'))
|
|
190
|
+
const subject = str(data, 'subject', t.t('notification.render.pm.fallbackSubject'))
|
|
191
|
+
return {
|
|
192
|
+
subject: t.t('notification.render.pm.receivedSubject', { from }),
|
|
193
|
+
body: `“${subject}”`,
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
'pm.receipt': (data, t) => {
|
|
198
|
+
const by = str(data, 'byUsername', t.t('notification.render.pm.recipientFallback'))
|
|
199
|
+
const subject = str(data, 'subject', t.t('notification.render.pm.messageFallback'))
|
|
200
|
+
return {
|
|
201
|
+
subject: t.t('notification.render.pm.receiptSubject', { by }),
|
|
202
|
+
body: t.t('notification.render.pm.receiptBody', { subject }),
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
'system.task_failed': (data, t) => {
|
|
207
|
+
const taskId = str(data, 'taskId', t.t('notification.render.task.fallbackId'))
|
|
208
|
+
const error = str(data, 'error')
|
|
209
|
+
return {
|
|
210
|
+
subject: t.t('notification.render.task.subject', { taskId }),
|
|
211
|
+
body:
|
|
212
|
+
t.t('notification.render.task.body', { taskId }) +
|
|
213
|
+
(error === '' ? '' : `\n\n${t.t('notification.render.task.error', { error })}`),
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
'marketplace.update_available': (data, t) => {
|
|
218
|
+
const name = str(data, 'name', str(data, 'key'))
|
|
219
|
+
const version = str(data, 'version')
|
|
220
|
+
const packageName = str(data, 'package')
|
|
221
|
+
return {
|
|
222
|
+
subject: t.t('notification.render.marketplace.subject', { name, version }),
|
|
223
|
+
body: t.t('notification.render.marketplace.body', { package: packageName, version }),
|
|
224
|
+
}
|
|
225
|
+
},
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function renderNotification(
|
|
229
|
+
record: NotificationRecord,
|
|
230
|
+
t: Translator = sourceTranslator(EN_CATALOG),
|
|
231
|
+
): NotificationView {
|
|
232
|
+
const template = TEMPLATES[record.kind]
|
|
233
|
+
const spec = notificationKind(record.kind)
|
|
234
|
+
|
|
235
|
+
const rendered =
|
|
236
|
+
template !== undefined
|
|
237
|
+
? template(record.data, t)
|
|
238
|
+
: record.kind.startsWith('plugin.')
|
|
239
|
+
? {
|
|
240
|
+
subject:
|
|
241
|
+
pluginMessage(record.data, 'subjectKey', 'subjectArgs', 'subject', t) ||
|
|
242
|
+
t.t('notification.render.pluginFallback'),
|
|
243
|
+
body: pluginMessage(record.data, 'bodyKey', 'bodyArgs', 'body', t),
|
|
244
|
+
}
|
|
245
|
+
: {
|
|
246
|
+
subject:
|
|
247
|
+
spec?.titleKey === undefined
|
|
248
|
+
? (spec?.title ?? t.t('notification.render.generic', { kind: record.kind }))
|
|
249
|
+
: t.t(spec.titleKey),
|
|
250
|
+
body: '',
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const subject =
|
|
254
|
+
record.occurrences > 1
|
|
255
|
+
? t.t('notification.render.occurrences', {
|
|
256
|
+
subject: rendered.subject,
|
|
257
|
+
count: record.occurrences,
|
|
258
|
+
})
|
|
259
|
+
: rendered.subject
|
|
260
|
+
|
|
261
|
+
const unsubscribe = record.data.unsubscribe
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
id: record.id,
|
|
265
|
+
subject,
|
|
266
|
+
body: rendered.body,
|
|
267
|
+
unsubscribeToken: typeof unsubscribe === 'string' && unsubscribe !== '' ? unsubscribe : null,
|
|
268
|
+
href: record.href,
|
|
269
|
+
kind: record.kind,
|
|
270
|
+
occurrences: record.occurrences,
|
|
271
|
+
createdAt: record.createdAt,
|
|
272
|
+
updatedAt: record.updatedAt,
|
|
273
|
+
isRead: record.readAt !== null,
|
|
274
|
+
}
|
|
275
|
+
}
|