@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 +10 -2
- package/src/decorators/with-live-reload/index.js +113 -0
- package/src/push/index.js +142 -0
- package/types/decorators/with-live-reload.d.ts +30 -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": [
|
|
@@ -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": ">=
|
|
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
|
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
|