@inglorious/web 6.11.0 → 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.11.0",
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": [
@@ -66,6 +66,10 @@
66
66
  "types": "./types/pwa.d.ts",
67
67
  "import": "./src/pwa/index.js"
68
68
  },
69
+ "./push": {
70
+ "types": "./types/push.d.ts",
71
+ "import": "./src/push/index.js"
72
+ },
69
73
  "./router": {
70
74
  "types": "./types/router.d.ts",
71
75
  "import": "./src/router/index.js"
@@ -127,7 +131,7 @@
127
131
  "vitest": "^5.0.0"
128
132
  },
129
133
  "engines": {
130
- "node": ">= 22"
134
+ "node": ">= 26"
131
135
  },
132
136
  "scripts": {
133
137
  "format": "prettier --write .",
@@ -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,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