@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 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.1.0",
4
- "description": "Notificaciones compartidas del ecosistema Closer Click: controlador data-agnostic (permiso del navegador + preferencias por categoría con scope por app + disparo) y el Web Component <closer-click-notifications> (panel de ajustes). Incluye helper de Web Push ligado al vault + proxy. Autohosteado, Shadow DOM, sin JS de terceros ni cookies.",
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): Required<Pick<PushProvider, 'supported' | 'isEnabled' | 'busy' | 'error' | 'enable' | 'disable' | 'ensureSubscribed'>>
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) Web Component ── <closer-click-notifications> (panel de ajustes)
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 = {