@inglorious/web 6.10.2 → 6.12.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/web",
3
- "version": "6.10.2",
3
+ "version": "6.12.0",
4
4
  "description": "A new web framework that leverages the power of the Inglorious Store combined with the performance and simplicity of lit-html.",
5
5
  "author": "Matteo Antony Mistretta <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
6
6
  "contributors": [
@@ -41,6 +41,10 @@
41
41
  "types": "./types/decorators/with-error-boundary.d.ts",
42
42
  "import": "./src/decorators/with-error-boundary/index.js"
43
43
  },
44
+ "./decorators/with-live-reload": {
45
+ "types": "./types/decorators/with-live-reload.d.ts",
46
+ "import": "./src/decorators/with-live-reload/index.js"
47
+ },
44
48
  "./decorators/with-throttle": {
45
49
  "types": "./types/decorators/with-throttle.d.ts",
46
50
  "import": "./src/decorators/with-throttle/index.js"
@@ -62,6 +66,10 @@
62
66
  "types": "./types/pwa.d.ts",
63
67
  "import": "./src/pwa/index.js"
64
68
  },
69
+ "./push": {
70
+ "types": "./types/push.d.ts",
71
+ "import": "./src/push/index.js"
72
+ },
65
73
  "./router": {
66
74
  "types": "./types/router.d.ts",
67
75
  "import": "./src/router/index.js"
@@ -123,7 +131,7 @@
123
131
  "vitest": "^5.0.0"
124
132
  },
125
133
  "engines": {
126
- "node": ">= 22"
134
+ "node": ">= 26"
127
135
  },
128
136
  "scripts": {
129
137
  "format": "prettier --write .",
@@ -0,0 +1,113 @@
1
+ import { debounce } from "@inglorious/utils/functions"
2
+
3
+ const DEFAULT_DELAY = 300
4
+
5
+ // Every live entity's reload, across all the types that use the decorator, so
6
+ // one listener can reload them all when the page comes back into view.
7
+ const reloads = new Set()
8
+ let isWatchingVisibility = false
9
+
10
+ /**
11
+ * Keeps an entity in step with a backend: whenever one of the `tables` it
12
+ * reads changes, it reloads, by notifying its own `event` (default `"load"`).
13
+ * There is no matching of which rows changed: a change to a table the entity
14
+ * reads is enough. That costs a reload now and then, and spares every type
15
+ * the rules that decide whether a change concerns it, which is where live
16
+ * data goes wrong.
17
+ *
18
+ * - `source` delivers the changes: `subscribe(table, onChange)` returns an
19
+ * unsubscribe function. `@inglorious/supabase` has one for Supabase
20
+ * Realtime.
21
+ * - `payload(entity, api)` is what the event carries, such as the id of the
22
+ * record the entity shows.
23
+ * - `isLive(entity, api)` says whether the entity is on screen. A change
24
+ * while it is not is skipped: it loads when it opens anyway. Defaults to
25
+ * always.
26
+ * - `delay` (default 300 ms) gathers a burst of changes into one reload.
27
+ *
28
+ * It also reloads every live entity when the page comes back into view: a
29
+ * phone that put the app in the background may have dropped the connection,
30
+ * and the changes with it.
31
+ *
32
+ * IMPORTANT: changes arrive long after the handler that subscribed has
33
+ * returned, when its `entity` draft is revoked. So only the entity's `id` is
34
+ * held, and the live entity is read from `api.getEntity(id)` when a change
35
+ * comes.
36
+ *
37
+ * @param {Object} config
38
+ * @param {{ subscribe(table: string, onChange: () => void): () => void }} config.source
39
+ * @param {string[]} config.tables - The tables the entity reads.
40
+ * @param {string} [config.event] - The event that reloads it.
41
+ * @param {(entity: Object, api: Object) => unknown} [config.payload]
42
+ * @param {(entity: Object, api: Object) => boolean} [config.isLive]
43
+ * @param {number} [config.delay]
44
+ *
45
+ * @example
46
+ * const types = {
47
+ * Inbox: [
48
+ * Inbox,
49
+ * withLiveReload({
50
+ * source: realtimeSource(supabase),
51
+ * tables: ["messages"],
52
+ * payload: (entity) => entity.folderId,
53
+ * }),
54
+ * ],
55
+ * }
56
+ */
57
+ export function withLiveReload({
58
+ source,
59
+ tables,
60
+ event = "load",
61
+ payload,
62
+ isLive = () => true,
63
+ delay = DEFAULT_DELAY,
64
+ }) {
65
+ return (type) => {
66
+ // entity id -> what to cancel when it goes
67
+ const subscriptions = new Map()
68
+
69
+ function stop(id) {
70
+ const subscription = subscriptions.get(id)
71
+ if (!subscription) return
72
+ subscription.reload.cancel()
73
+ subscription.unsubscribes.forEach((unsubscribe) => unsubscribe())
74
+ reloads.delete(subscription.reload)
75
+ subscriptions.delete(id)
76
+ }
77
+
78
+ return {
79
+ create(entity, createPayload, api) {
80
+ type.create?.(entity, createPayload, api)
81
+
82
+ const id = entity.id // capture the primitive now, while the draft is live
83
+ stop(id)
84
+ const reload = debounce(() => {
85
+ const live = api.getEntity(id) // fresh, live reference
86
+ if (!live || !isLive(live, api)) return
87
+ api.notify(`#${id}:${event}`, payload?.(live, api))
88
+ }, delay)
89
+
90
+ subscriptions.set(id, {
91
+ reload,
92
+ unsubscribes: tables.map((table) => source.subscribe(table, reload)),
93
+ })
94
+ reloads.add(reload)
95
+ watchVisibility()
96
+ },
97
+
98
+ destroy(entity, destroyPayload, api) {
99
+ stop(entity.id)
100
+ type.destroy?.(entity, destroyPayload, api)
101
+ },
102
+ }
103
+ }
104
+ }
105
+
106
+ function watchVisibility() {
107
+ if (isWatchingVisibility || typeof document === "undefined") return
108
+ isWatchingVisibility = true
109
+ document.addEventListener("visibilitychange", () => {
110
+ if (document.visibilityState !== "visible") return
111
+ reloads.forEach((reload) => reload())
112
+ })
113
+ }
@@ -0,0 +1,142 @@
1
+ import { handleAsync } from "@inglorious/store/async"
2
+
3
+ const DEFAULT_DECLINE_KEY = "inglorious-push-declined"
4
+
5
+ // The adapters whose taps are already wired: an entity created again (a hot
6
+ // update, a second store) must not open each push twice.
7
+ const tappedAdapters = new WeakSet()
8
+
9
+ /**
10
+ * The type that asks a device for push notifications and keeps it registered.
11
+ *
12
+ * A device shows its permission dialog only so many times, and a refusal
13
+ * there may be for good. So the type asks in the app's own words first: an
14
+ * entity with `isOpen` the app renders as a prompt, whose yes brings up the
15
+ * system's dialog (`pushAsk`) and whose no (`pushDecline`) is remembered on
16
+ * the device, so it never asks again. Allowed later from the system's
17
+ * settings, pushes still register.
18
+ *
19
+ * The app tells it when there is someone to register for: `pushStart` (at
20
+ * sign-in) checks the permission, registers the device if it is granted, and
21
+ * otherwise opens the prompt; `pushStop` (at sign-out) closes it. Map them
22
+ * onto the app's own events, such as `sessionStart: type.pushStart`.
23
+ *
24
+ * Everything vendor-specific is the `adapter`'s:
25
+ *
26
+ * - `isSupported()`: whether this device can receive pushes at all.
27
+ * - `permission()`: `"granted"`, `"denied"` or `"prompt"`.
28
+ * - `request()`: brings up the system's dialog, resolving to the same. In a
29
+ * browser it must call `Notification.requestPermission()` before it awaits
30
+ * anything, since the dialog needs the tap that asked.
31
+ * - `register()`: gets the device's token and stores it for the signed-in
32
+ * user. Unregistering is the app's to do at sign-out, while the user can
33
+ * still delete their own token.
34
+ * - `onTap(listener)`, optional: calls `listener(url)` when a push is tapped,
35
+ * where the system shows them itself (native apps). The type navigates
36
+ * there with `onTap(url, api)`, by default `#router:navigate`.
37
+ *
38
+ * @param {Object} config
39
+ * @param {Object} config.adapter
40
+ * @param {string} [config.declineKey] - The localStorage key of a decline.
41
+ * @param {(url: string, api: Object) => void} [config.onTap]
42
+ *
43
+ * @example
44
+ * const prompt = createPushPrompt({ adapter: fcm })
45
+ * const types = {
46
+ * PushPrompt: {
47
+ * ...prompt,
48
+ * ...renderers,
49
+ * sessionStart: prompt.pushStart,
50
+ * sessionEnd: prompt.pushStop,
51
+ * },
52
+ * }
53
+ */
54
+ export function createPushPrompt({
55
+ adapter,
56
+ declineKey = DEFAULT_DECLINE_KEY,
57
+ onTap = navigate,
58
+ }) {
59
+ function isDeclined() {
60
+ try {
61
+ return window.localStorage.getItem(declineKey) !== null
62
+ } catch {
63
+ return false
64
+ }
65
+ }
66
+
67
+ return {
68
+ create(entity, _, api) {
69
+ entity.isOpen ??= false
70
+ entity.isActive ??= false
71
+ if (adapter.onTap && !tappedAdapters.has(adapter)) {
72
+ tappedAdapters.add(adapter)
73
+ adapter.onTap((url) => onTap(url, api))
74
+ }
75
+ },
76
+
77
+ pushStart(entity, _, api) {
78
+ entity.isActive = true
79
+ api.notify(`#${entity.id}:pushCheck`)
80
+ },
81
+
82
+ pushStop(entity) {
83
+ entity.isActive = false
84
+ entity.isOpen = false
85
+ },
86
+
87
+ // Asking the device is async, so a stop can land before the answer: the
88
+ // prompt only opens while started.
89
+ ...handleAsync("pushCheck", {
90
+ async run() {
91
+ if (!(await adapter.isSupported())) return null
92
+ return adapter.permission()
93
+ },
94
+ success(entity, permission, api) {
95
+ if (!entity.isActive) return
96
+ if (permission === "granted") {
97
+ api.notify(`#${entity.id}:pushRegister`)
98
+ return
99
+ }
100
+ entity.isOpen = permission === "prompt" && !isDeclined()
101
+ },
102
+ error() {},
103
+ }),
104
+
105
+ // The prompt goes down as the system's dialog comes up. Dismissing the
106
+ // dialog leaves the permission undecided, so the next start asks again.
107
+ ...handleAsync("pushAsk", {
108
+ start(entity) {
109
+ entity.isOpen = false
110
+ },
111
+ run() {
112
+ return adapter.request()
113
+ },
114
+ success(entity, permission, api) {
115
+ if (permission === "granted") api.notify(`#${entity.id}:pushRegister`)
116
+ },
117
+ error() {},
118
+ }),
119
+
120
+ pushDecline(entity) {
121
+ try {
122
+ window.localStorage.setItem(declineKey, "true")
123
+ } catch {
124
+ // Without storage the prompt comes back next start, nothing worse.
125
+ }
126
+ entity.isOpen = false
127
+ },
128
+
129
+ // Nothing on screen depends on it: a device that fails to register now
130
+ // tries again at the next start.
131
+ ...handleAsync("pushRegister", {
132
+ run() {
133
+ return adapter.register()
134
+ },
135
+ error() {},
136
+ }),
137
+ }
138
+ }
139
+
140
+ function navigate(url, api) {
141
+ api.notify("#router:navigate", url)
142
+ }
@@ -0,0 +1,30 @@
1
+ import { Api } from "../mount"
2
+
3
+ /** Delivers a backend's changes, one table at a time. */
4
+ export interface LiveSource {
5
+ /** Calls `onChange` on every change to `table`; returns the unsubscribe. */
6
+ subscribe(table: string, onChange: () => void): () => void
7
+ }
8
+
9
+ export interface LiveReloadConfig<TEntity = any> {
10
+ source: LiveSource
11
+ /** The tables the entity reads. */
12
+ tables: string[]
13
+ /** The event that reloads the entity. Defaults to `"load"`. */
14
+ event?: string
15
+ /** What the event carries, such as the id of the record the entity shows. */
16
+ payload?: (entity: TEntity, api: Api) => unknown
17
+ /** Whether the entity is on screen. Defaults to always. */
18
+ isLive?: (entity: TEntity, api: Api) => boolean
19
+ /** Gathers a burst of changes into one reload. Defaults to 300 ms. */
20
+ delay?: number
21
+ }
22
+
23
+ export interface LiveReloadBehavior {
24
+ create: (entity: any, payload?: any, api?: Api) => void
25
+ destroy: (entity: any, payload?: any, api?: Api) => void
26
+ }
27
+
28
+ export declare function withLiveReload<TEntity = any>(
29
+ config: LiveReloadConfig<TEntity>,
30
+ ): <T extends Record<string, any>>(type: T) => LiveReloadBehavior
@@ -0,0 +1,54 @@
1
+ import { Api } from "./mount"
2
+
3
+ export type PushPermission = "granted" | "denied" | "prompt"
4
+
5
+ /** Everything vendor-specific about pushes: the device, and where its token goes. */
6
+ export interface PushAdapter {
7
+ /** Whether this device can receive pushes at all. */
8
+ isSupported(): Promise<boolean>
9
+ permission(): Promise<PushPermission>
10
+ /**
11
+ * Brings up the system's dialog. In a browser it must call
12
+ * `Notification.requestPermission()` before it awaits anything.
13
+ */
14
+ request(): Promise<PushPermission>
15
+ /** Gets the device's token and stores it for the signed-in user. */
16
+ register(): Promise<void>
17
+ /** Calls `listener(url)` when a push the system showed is tapped. */
18
+ onTap?(listener: (url: string) => void): void
19
+ }
20
+
21
+ export interface PushPromptConfig {
22
+ adapter: PushAdapter
23
+ /** The localStorage key of a decline. Defaults to `"inglorious-push-declined"`. */
24
+ declineKey?: string
25
+ /** Opens a tapped push's `url`. Defaults to `#router:navigate`. */
26
+ onTap?: (url: string, api: Api) => void
27
+ }
28
+
29
+ export interface PushPromptEntity {
30
+ id?: string
31
+ type: string
32
+ /** The app's own prompt is showing. */
33
+ isOpen: boolean
34
+ /** Between `pushStart` and `pushStop`: someone to register for. */
35
+ isActive: boolean
36
+ }
37
+
38
+ export type PushPromptType = {
39
+ create(entity: PushPromptEntity, payload?: unknown, api?: Api): void
40
+ /** Checks the permission: registers if granted, else opens the prompt. */
41
+ pushStart(entity: PushPromptEntity, payload?: unknown, api?: Api): void
42
+ /** Closes the prompt. */
43
+ pushStop(entity: PushPromptEntity): void
44
+ /** The prompt's yes: brings up the system's dialog. */
45
+ pushAsk(entity: PushPromptEntity, payload?: unknown, api?: Api): void
46
+ /** The prompt's no, remembered on this device. */
47
+ pushDecline(entity: PushPromptEntity): void
48
+ [handler: string]: unknown
49
+ }
50
+
51
+ /** The type that asks a device for pushes and keeps it registered. */
52
+ export declare function createPushPrompt(
53
+ config: PushPromptConfig,
54
+ ): PushPromptType