@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 +6 -2
- package/src/push/index.js +142 -0
- package/types/push.d.ts +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/web",
|
|
3
|
-
"version": "6.
|
|
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": ">=
|
|
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
|
+
}
|
package/types/push.d.ts
ADDED
|
@@ -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
|