@closerclick/closer-click-notifications 0.1.0 → 0.2.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 +52 -0
- package/package.json +2 -2
- package/src/index.d.ts +62 -1
- package/src/index.js +185 -1
package/README.md
CHANGED
|
@@ -11,6 +11,10 @@ app reimplementaba por separado (messenger, eco, chess, pronóstico, gymbro):
|
|
|
11
11
|
temable por CSS vars (`--ccn-*`). **Sin JS de terceros ni cookies.**
|
|
12
12
|
3. **`createVaultPushProvider(...)`** — Web Push (app cerrada) ligado al transporte
|
|
13
13
|
del ecosistema (`closer-click-proxy-client`) y firmado por el vault de identidad.
|
|
14
|
+
4. **`createShareReceipts(...)`** — **acuses de apertura** de contenido compartido:
|
|
15
|
+
el autor recibe una notificación (con el mismo enlace) cuando un tercero abre lo
|
|
16
|
+
que compartió. Mismo transporte (proxy) e identidad (vault); contenido por la
|
|
17
|
+
cola cifrada, no por el push.
|
|
14
18
|
|
|
15
19
|
El ecosistema es mixto Vue/vanilla → la UI va como **custom element** (mismo patrón
|
|
16
20
|
que `closer-click-support` / `closer-click-profile` / `closer-click-nav`).
|
|
@@ -115,6 +119,54 @@ El "timbre" **no transporta contenido**: el Service Worker despierta y la app
|
|
|
115
119
|
reconecta e `identify()` drena la cola cifrada del proxy. La subscription se liga
|
|
116
120
|
a la **misma pubkey del vault** usada en `identify`, con un sobre firmado por el vault.
|
|
117
121
|
|
|
122
|
+
## Acuses de apertura (`createShareReceipts`)
|
|
123
|
+
|
|
124
|
+
Avisa al **autor** cuando un tercero **abre** un contenido que compartió, con el
|
|
125
|
+
**mismo enlace** de vuelta (para re-ver el contenido desde la notificación). Solo
|
|
126
|
+
funciona si el enlace permite recuperar la **pubkey del autor** (p. ej. el blob
|
|
127
|
+
firmado del pronosticador): así el que abre puede enrutar el acuse por
|
|
128
|
+
`sendByPubkey` (cola offline 24h del proxy).
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
import { createNotifications, createShareReceipts } from '@closerclick/closer-click-notifications'
|
|
132
|
+
import { getWebSocketProxyClient } from '@closerclick/closer-click-proxy-client'
|
|
133
|
+
import { Identity } from '@closerclick/closer-click-identity'
|
|
134
|
+
|
|
135
|
+
const notifications = createNotifications({
|
|
136
|
+
storageKey: 'mundial',
|
|
137
|
+
categories: [
|
|
138
|
+
{ key: 'shareOpened', label: { es: 'Aperturas de lo que compartí', en: 'Opens of what I shared' } },
|
|
139
|
+
],
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const receipts = createShareReceipts({
|
|
143
|
+
proxyClient: () => getWebSocketProxyClient(),
|
|
144
|
+
identity: () => Identity.connect(),
|
|
145
|
+
notifications, // dispara notify('shareOpened', …) con data.url
|
|
146
|
+
// render(env) → { title, body, ... } | null (opcional: personaliza el contenido)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Lado AUTOR (escuchar acuses entrantes mientras la app está abierta):
|
|
150
|
+
receipts.start()
|
|
151
|
+
|
|
152
|
+
// Lado del que ABRE (al importar contenido AJENO firmado):
|
|
153
|
+
await receipts.report({ toPubkey: parsed.publickey, url: sharedUrl, name: parsed.name })
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- **Identidad del que abre**: el sobre incluye **siempre** `from { pubkey, nick }`.
|
|
157
|
+
- **Anti-spam**: `report` aplica throttle por `(toPubkey|url)` (default 24h).
|
|
158
|
+
- **Propio**: `report` es no-op si el contenido es tuyo (`toPubkey === tu pubkey`).
|
|
159
|
+
- **Contenido por defecto** (mismo en toda app, es/en): *"Abrieron tu contenido"* +
|
|
160
|
+
*«name» · nick*. Pásalo a medida con `render(env)`.
|
|
161
|
+
- **Offline**: el contenido viaja por la **cola del proxy** (no por el push). El
|
|
162
|
+
push solo "timbra"; al reabrir la app, `identify()` drena la cola y aparece la
|
|
163
|
+
notificación con el enlace. El click usa `data.url` — tu SW
|
|
164
|
+
(`closer-click-push-sw.js`) ya navega a `event.notification.data.url`.
|
|
165
|
+
|
|
166
|
+
> Apps de **partida en vivo** (chess/cuarenta comparten `#table=<token>` sin pubkey
|
|
167
|
+
> del autor) no usan este acuse: el anfitrión ya ve el "join" por el canal. Pueden
|
|
168
|
+
> enrutar ese evento por el mismo `createNotifications` para una UX uniforme.
|
|
169
|
+
|
|
118
170
|
## Tema (CSS custom properties)
|
|
119
171
|
|
|
120
172
|
```css
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@closerclick/closer-click-notifications",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Notificaciones compartidas del ecosistema Closer Click: controlador data-agnostic (permiso del navegador + preferencias por categoría con scope por app + disparo)
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Notificaciones compartidas del ecosistema Closer Click: controlador data-agnostic (permiso del navegador + preferencias por categoría con scope por app + disparo), el Web Component <closer-click-notifications> (panel de ajustes), helper de Web Push ligado al vault + proxy y acuses de apertura de contenido compartido (createShareReceipts). Autohosteado, Shadow DOM, sin JS de terceros ni cookies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"module": "src/index.js",
|
package/src/index.d.ts
CHANGED
|
@@ -76,8 +76,69 @@ export interface VaultPushProviderConfig {
|
|
|
76
76
|
storageKey?: string
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/** Provider de Web Push concreto (todos los miembros son funciones llamables). */
|
|
80
|
+
export interface VaultPushProvider {
|
|
81
|
+
supported(): boolean
|
|
82
|
+
isEnabled(): boolean
|
|
83
|
+
busy(): boolean
|
|
84
|
+
error(): string
|
|
85
|
+
enable(): Promise<boolean>
|
|
86
|
+
disable(): Promise<boolean>
|
|
87
|
+
ensureSubscribed(): Promise<void>
|
|
88
|
+
}
|
|
89
|
+
|
|
79
90
|
/** Provider de Web Push ligado al vault + proxy del ecosistema. */
|
|
80
|
-
export function createVaultPushProvider(cfg: VaultPushProviderConfig):
|
|
91
|
+
export function createVaultPushProvider(cfg: VaultPushProviderConfig): VaultPushProvider
|
|
92
|
+
|
|
93
|
+
/** Sobre estándar de un acuse de apertura (lo que viaja por el proxy). */
|
|
94
|
+
export interface ShareReceiptEnvelope {
|
|
95
|
+
/** marca + versión del sobre (= 1). */
|
|
96
|
+
__ccn: number
|
|
97
|
+
/** tipo de acuse ('opened' por defecto, extensible). */
|
|
98
|
+
kind: string
|
|
99
|
+
/** enlace compartido (vuelve al autor para re-ver el contenido). */
|
|
100
|
+
url: string
|
|
101
|
+
/** nombre/título del contenido, si la app lo pasó. */
|
|
102
|
+
name?: string | null
|
|
103
|
+
/** identidad del que abrió (siempre identificado por decisión del ecosistema). */
|
|
104
|
+
from: { pubkey: string | null; nick: string | null }
|
|
105
|
+
/** instante del acuse (ms epoch). */
|
|
106
|
+
ts: number
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface ShareReceiptsConfig {
|
|
110
|
+
/** Cliente proxy del ecosistema (o getter): on('message',…) + sendByPubkey. */
|
|
111
|
+
proxyClient: any | (() => any)
|
|
112
|
+
/** Instancia Identity del vault (o getter, puede ser async). */
|
|
113
|
+
identity: any | (() => any | Promise<any>)
|
|
114
|
+
/** Controlador de createNotifications(...) (para notify/prefs). */
|
|
115
|
+
notifications: NotificationsController
|
|
116
|
+
/** Categoría de prefs a respetar/disparar (default 'shareOpened'). */
|
|
117
|
+
category?: string
|
|
118
|
+
/** Override del contenido: recibe el sobre, devuelve NotifyOptions o null. */
|
|
119
|
+
render?: (env: ShareReceiptEnvelope) => (NotifyOptions | null)
|
|
120
|
+
/** Idioma del render por defecto ('es'|'en'|'auto', default 'auto'). */
|
|
121
|
+
lang?: string
|
|
122
|
+
/** Ventana anti-spam por contenido en ms (default 24h). */
|
|
123
|
+
throttleMs?: number
|
|
124
|
+
/** Override del click (default: navegar a url). */
|
|
125
|
+
onOpen?: (url: string, env: ShareReceiptEnvelope) => void
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface ShareReceipts {
|
|
129
|
+
/** Lado del que ABRE: avisa al autor que abriste su contenido. */
|
|
130
|
+
report(opts: { toPubkey: string; url: string; kind?: string; name?: string }): Promise<boolean>
|
|
131
|
+
/** Lado AUTOR: empieza a escuchar acuses entrantes (idempotente). */
|
|
132
|
+
start(): void
|
|
133
|
+
/** Deja de escuchar. */
|
|
134
|
+
stop(): void
|
|
135
|
+
readonly category: string
|
|
136
|
+
readonly RECEIPT_TAG: string
|
|
137
|
+
readonly RECEIPT_VERSION: number
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Motor común de acuses de apertura de contenido compartido. */
|
|
141
|
+
export function createShareReceipts(cfg: ShareReceiptsConfig): ShareReceipts
|
|
81
142
|
|
|
82
143
|
export class CloserClickNotifications extends HTMLElement {
|
|
83
144
|
controller: NotificationsController | null
|
package/src/index.js
CHANGED
|
@@ -244,7 +244,191 @@ export function createVaultPushProvider ({ proxyClient, identity, storageKey = '
|
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
/* ============================================================================
|
|
247
|
-
* 3)
|
|
247
|
+
* 3) Acuses de apertura ── createShareReceipts(...)
|
|
248
|
+
*
|
|
249
|
+
* Mecanismo COMÚN para que el autor de un contenido compartido reciba un aviso
|
|
250
|
+
* cuando un tercero abre el enlace, con el MISMO enlace de vuelta (para re-ver
|
|
251
|
+
* el contenido desde la notificación). Reutilizable por cualquier app del
|
|
252
|
+
* ecosistema.
|
|
253
|
+
*
|
|
254
|
+
* - Lado del que ABRE: report({ toPubkey, url, kind, name }) → manda un
|
|
255
|
+
* sobre firmable por la cola offline del proxy (sendByPubkey, 24h). No
|
|
256
|
+
* avisa si el contenido es propio (toPubkey === mi pubkey) y aplica
|
|
257
|
+
* throttle por (toPubkey|url) para no spamear al reabrir.
|
|
258
|
+
* - Lado AUTOR: start() → escucha mensajes del proxy, filtra los
|
|
259
|
+
* sobres de acuse (__ccn) y dispara una notificación local con el enlace
|
|
260
|
+
* (data.url). El CONTENIDO por defecto es el mismo en toda app; la app
|
|
261
|
+
* puede inyectar render(env) para personalizarlo.
|
|
262
|
+
*
|
|
263
|
+
* El transporte y la identidad NO se reimplementan: se inyectan los mismos
|
|
264
|
+
* `proxyClient` (closer-click-proxy-client) e `identity` (vault) que usa el
|
|
265
|
+
* resto del ecosistema. El contenido viaja por la cola CIFRADA del proxy, no
|
|
266
|
+
* por el push (el push solo "timbra" sin contenido — política del ecosistema).
|
|
267
|
+
* ==========================================================================*/
|
|
268
|
+
|
|
269
|
+
const RECEIPT_TAG = '__ccn' // marca del sobre (envelope) de acuse
|
|
270
|
+
const RECEIPT_VERSION = 1
|
|
271
|
+
const LS_THROTTLE = 'cc-receipt:' // namespace del anti-spam por contenido
|
|
272
|
+
|
|
273
|
+
const RECEIPT_I18N = {
|
|
274
|
+
es: {
|
|
275
|
+
openedTitle: 'Abrieron tu contenido',
|
|
276
|
+
openedBodyNamed: (name, nick) => nick ? `Abrieron «${name}» · ${nick}` : `Abrieron «${name}»`,
|
|
277
|
+
openedBodyAnon: (nick) => nick ? `${nick} abrió lo que compartiste` : 'Alguien abrió lo que compartiste',
|
|
278
|
+
},
|
|
279
|
+
en: {
|
|
280
|
+
openedTitle: 'Your content was opened',
|
|
281
|
+
openedBodyNamed: (name, nick) => nick ? `“${name}” opened · ${nick}` : `“${name}” opened`,
|
|
282
|
+
openedBodyAnon: (nick) => nick ? `${nick} opened what you shared` : 'Someone opened what you shared',
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function _receiptLang (lang) {
|
|
287
|
+
const a = (lang || 'auto').toLowerCase()
|
|
288
|
+
if (a === 'es' || a === 'en') return a
|
|
289
|
+
const nav = (isBrowser && navigator.language || 'es').slice(0, 2)
|
|
290
|
+
return nav === 'en' ? 'en' : 'es'
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Crea el motor de acuses de apertura (data-agnostic, sin framework).
|
|
295
|
+
*
|
|
296
|
+
* @param {object} cfg
|
|
297
|
+
* @param {object|function} cfg.proxyClient cliente proxy del ecosistema (o getter).
|
|
298
|
+
* Debe exponer `on('message', (from, payload) => …)` y `sendByPubkey(pk, payload)`.
|
|
299
|
+
* @param {object|function} cfg.identity instancia Identity del vault (o getter async).
|
|
300
|
+
* @param {object} cfg.notifications controlador de createNotifications(...) (para notify/prefs).
|
|
301
|
+
* @param {string} [cfg.category='shareOpened'] categoría de prefs a respetar/disparar.
|
|
302
|
+
* @param {(env:object)=>(object|null)} [cfg.render] override del contenido: recibe el sobre
|
|
303
|
+
* y devuelve { title, body, ...NotifyOptions } o null para ignorar. Si no se pasa,
|
|
304
|
+
* usa el render por defecto (mismo contenido en toda app), bilingüe es/en.
|
|
305
|
+
* @param {string} [cfg.lang='auto'] idioma del render por defecto.
|
|
306
|
+
* @param {number} [cfg.throttleMs=86400000] ventana anti-spam por contenido (default 24h).
|
|
307
|
+
* @param {(url:string,env:object)=>void} [cfg.onOpen] override del click (default: navegar a url).
|
|
308
|
+
*/
|
|
309
|
+
export function createShareReceipts (cfg = {}) {
|
|
310
|
+
const getProxy = typeof cfg.proxyClient === 'function' ? cfg.proxyClient : () => cfg.proxyClient
|
|
311
|
+
const getId = typeof cfg.identity === 'function' ? cfg.identity : () => cfg.identity
|
|
312
|
+
const notifications = cfg.notifications || null
|
|
313
|
+
const category = cfg.category || 'shareOpened'
|
|
314
|
+
const render = typeof cfg.render === 'function' ? cfg.render : null
|
|
315
|
+
const lang = cfg.lang || 'auto'
|
|
316
|
+
const throttleMs = cfg.throttleMs != null ? cfg.throttleMs : 24 * 60 * 60 * 1000
|
|
317
|
+
const onOpen = typeof cfg.onOpen === 'function'
|
|
318
|
+
? cfg.onOpen
|
|
319
|
+
: (url) => { try { if (isBrowser && url) location.assign(url) } catch (_) {} }
|
|
320
|
+
|
|
321
|
+
let _off = null
|
|
322
|
+
const _seen = new Set() // dedup de acuses entrantes (from|url|ts)
|
|
323
|
+
|
|
324
|
+
async function _myPubkey () {
|
|
325
|
+
try {
|
|
326
|
+
const id = await getId()
|
|
327
|
+
return (id && id.me && id.me.publickey) || null
|
|
328
|
+
} catch (_) { return null }
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ---- anti-spam: 1 acuse por (toPubkey|url) por ventana (localStorage) ----
|
|
332
|
+
function _throttled (toPubkey, url) {
|
|
333
|
+
if (!isBrowser || throttleMs <= 0) return false
|
|
334
|
+
try {
|
|
335
|
+
const k = LS_THROTTLE + _hash(toPubkey + '|' + url)
|
|
336
|
+
const last = Number(localStorage.getItem(k) || 0)
|
|
337
|
+
const now = Date.now()
|
|
338
|
+
if (now - last < throttleMs) return true
|
|
339
|
+
localStorage.setItem(k, String(now))
|
|
340
|
+
return false
|
|
341
|
+
} catch (_) { return false }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Hash corto y estable (djb2) para no guardar la URL completa en la clave.
|
|
345
|
+
function _hash (s) {
|
|
346
|
+
let h = 5381
|
|
347
|
+
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0
|
|
348
|
+
return (h >>> 0).toString(36)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Lado del que ABRE: avisa al autor (toPubkey) que abriste su contenido.
|
|
353
|
+
* No-op si es contenido propio o si el throttle lo bloquea. Devuelve true si
|
|
354
|
+
* se encoló el acuse.
|
|
355
|
+
* @returns {Promise<boolean>}
|
|
356
|
+
*/
|
|
357
|
+
async function report ({ toPubkey, url, kind = 'opened', name } = {}) {
|
|
358
|
+
if (!toPubkey || !url) return false
|
|
359
|
+
const mine = await _myPubkey()
|
|
360
|
+
if (mine && mine === toPubkey) return false // no avisarme a mí mismo
|
|
361
|
+
if (_throttled(toPubkey, url)) return false
|
|
362
|
+
// Decisión del ecosistema: identificar SIEMPRE al que abre.
|
|
363
|
+
let from = { pubkey: mine || null, nick: null }
|
|
364
|
+
try {
|
|
365
|
+
const id = await getId()
|
|
366
|
+
from = { pubkey: (id && id.me && id.me.publickey) || mine || null, nick: (id && id.me && id.me.nickname) || null }
|
|
367
|
+
} catch (_) {}
|
|
368
|
+
const env = { [RECEIPT_TAG]: RECEIPT_VERSION, kind, url, name: name || null, from, ts: Date.now() }
|
|
369
|
+
try {
|
|
370
|
+
const proxy = getProxy()
|
|
371
|
+
if (typeof proxy.ensureConnected === 'function') { try { await proxy.ensureConnected() } catch (_) {} }
|
|
372
|
+
proxy.sendByPubkey(toPubkey, env)
|
|
373
|
+
return true
|
|
374
|
+
} catch (e) {
|
|
375
|
+
console.warn('[cc-notif] report (acuse) falló:', (e && e.message) || e)
|
|
376
|
+
return false
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Construye {title, body, ...} por defecto a partir del sobre (mismo contenido
|
|
381
|
+
// en toda app). La app puede sobreescribirlo con cfg.render.
|
|
382
|
+
function _defaultRender (env) {
|
|
383
|
+
const t = RECEIPT_I18N[_receiptLang(lang)]
|
|
384
|
+
const nick = env.from && env.from.nick
|
|
385
|
+
const title = t.openedTitle
|
|
386
|
+
const body = env.name ? t.openedBodyNamed(env.name, nick) : t.openedBodyAnon(nick)
|
|
387
|
+
return { title, body }
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function _handle (env) {
|
|
391
|
+
if (!env || env[RECEIPT_TAG] !== RECEIPT_VERSION || !env.url) return
|
|
392
|
+
const key = `${(env.from && env.from.pubkey) || ''}|${env.url}|${env.ts || ''}`
|
|
393
|
+
if (_seen.has(key)) return
|
|
394
|
+
_seen.add(key)
|
|
395
|
+
const rendered = render ? render(env) : _defaultRender(env)
|
|
396
|
+
if (!rendered) return
|
|
397
|
+
const { title, body, ...rest } = rendered
|
|
398
|
+
if (!notifications || typeof notifications.notify !== 'function') return
|
|
399
|
+
notifications.notify(category, {
|
|
400
|
+
title: title || '',
|
|
401
|
+
body: body || '',
|
|
402
|
+
tag: rest.tag || ('cc-receipt:' + _hash(env.url)),
|
|
403
|
+
data: { url: env.url, ...(rest.data || {}) },
|
|
404
|
+
onClick: () => onOpen(env.url, env),
|
|
405
|
+
...rest,
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Lado AUTOR: empieza a escuchar acuses entrantes (idempotente). */
|
|
410
|
+
function start () {
|
|
411
|
+
if (_off) return
|
|
412
|
+
const proxy = getProxy()
|
|
413
|
+
if (!proxy || typeof proxy.on !== 'function') return
|
|
414
|
+
_off = proxy.on('message', (_from, payload) => {
|
|
415
|
+
const env = (typeof payload === 'object' && payload) ? payload : _tryParse(payload)
|
|
416
|
+
_handle(env)
|
|
417
|
+
})
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function _tryParse (s) {
|
|
421
|
+
if (typeof s !== 'string') return null
|
|
422
|
+
try { return JSON.parse(s) } catch (_) { return null }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function stop () { if (_off) { try { _off() } catch (_) {} _off = null } }
|
|
426
|
+
|
|
427
|
+
return { report, start, stop, category, RECEIPT_TAG, RECEIPT_VERSION }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/* ============================================================================
|
|
431
|
+
* 4) Web Component ── <closer-click-notifications> (panel de ajustes)
|
|
248
432
|
* ==========================================================================*/
|
|
249
433
|
|
|
250
434
|
const I18N = {
|