pi-roundtable-web 0.24.2 → 0.26.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/CHANGELOG.md +48 -0
- package/README.md +195 -16
- package/dist/account-zk4efb87.css +102 -0
- package/dist/chunk-account-hw49gd0d.js +1 -0
- package/dist/chunk-action-menu-rm5cyqkj.js +1 -0
- package/dist/chunk-agent-b017vswv.js +1 -0
- package/dist/chunk-arranged-rooms-q5v3dm1r.js +1 -0
- package/dist/chunk-character-creator-5dxcvd16.js +1 -0
- package/dist/chunk-codeeditor-pyt09fwg.js +2 -0
- package/dist/chunk-confirm-521yqmta.js +1 -0
- package/dist/chunk-connectors-hrm06nx8.js +1 -0
- package/dist/chunk-conversations-e26rrf01.js +1 -0
- package/dist/chunk-drag-v41gx2mc.js +1 -0
- package/dist/chunk-entry-jxxb3ath.js +1 -0
- package/dist/chunk-folder-list-b97xk12b.js +1 -0
- package/dist/chunk-folds-b8q7eags.js +1 -0
- package/dist/chunk-gate-8k61stsc.js +1 -0
- package/dist/chunk-group-scene-dx6wt19m.js +1 -0
- package/dist/chunk-group-table-e4jr8r3a.js +1 -0
- package/dist/chunk-host-state-g7e7c0k5.js +1 -0
- package/dist/chunk-host-ui-x7k6n07v.js +939 -0
- package/dist/chunk-index-014rjv9k.js +2 -0
- package/dist/chunk-index-2d0kg096.js +2 -0
- package/dist/chunk-index-2qd81wbx.js +2 -0
- package/dist/chunk-index-2stynxw6.css +1 -0
- package/dist/chunk-index-459drsft.js +2 -0
- package/dist/chunk-index-4mfj596y.js +2 -0
- package/dist/chunk-index-66nskrdj.js +47 -0
- package/dist/chunk-index-awx6nnta.js +2 -0
- package/dist/chunk-index-bbsytycb.js +2 -0
- package/dist/chunk-index-c07p8k0x.js +4 -0
- package/dist/chunk-index-cq1e9xdz.js +2 -0
- package/dist/chunk-index-d1prgfbp.js +2 -0
- package/dist/chunk-index-dhpt8qr9.js +2 -0
- package/dist/chunk-index-e5sd30xy.js +2 -0
- package/dist/chunk-index-g351921r.js +1 -0
- package/dist/chunk-index-hby0ap91.js +2 -0
- package/dist/chunk-index-hssjbr1k.js +2 -0
- package/dist/chunk-index-k2qq1gdj.js +2 -0
- package/dist/chunk-index-ke9ynm0n.js +2 -0
- package/dist/chunk-index-km40bhsz.js +2 -0
- package/dist/chunk-index-kpscvg85.js +2 -0
- package/dist/chunk-index-m0d100mg.js +2 -0
- package/dist/chunk-index-mb72snk9.js +2 -0
- package/dist/chunk-index-mjqqver5.js +4 -0
- package/dist/chunk-index-nqfkwkfp.js +2 -0
- package/dist/chunk-index-nwqk447w.js +2 -0
- package/dist/chunk-index-pcfhpm8v.js +302 -0
- package/dist/chunk-index-q8h8xzbm.js +2 -0
- package/dist/chunk-index-r5vszgfs.js +2 -0
- package/dist/{chunk-index-mpm90318.js → chunk-index-rhtvcbsq.js} +2 -2
- package/dist/chunk-index-rjv7vrwd.js +13 -0
- package/dist/chunk-index-v23m38p6.js +50 -0
- package/dist/chunk-index-va0g7d88.js +1 -0
- package/dist/{chunk-index-6t68gbex.js → chunk-index-wnjp6wv0.js} +2 -2
- package/dist/chunk-index-xgkd15n3.js +2 -0
- package/dist/chunk-index-y8rnq8zf.js +14 -0
- package/dist/chunk-index-z3k18atc.js +2 -0
- package/dist/chunk-lang-css-6bepqpnx.js +1 -0
- package/dist/{chunk-lang-dockerfile-ey0nvhhm.js → chunk-lang-dockerfile-hrav55t7.js} +1 -1
- package/dist/{chunk-lang-go-403qsw9f.js → chunk-lang-go-x9ahv6e5.js} +3 -3
- package/dist/chunk-lang-html-8jc72zt4.js +1 -0
- package/dist/chunk-lang-javascript-2p2qtbnf.js +1 -0
- package/dist/chunk-lang-json-hsemyd0r.js +1 -0
- package/dist/chunk-lang-markdown-zkwb4c9n.js +6 -0
- package/dist/{chunk-lang-python-9jk5a49y.js → chunk-lang-python-tewx7006.js} +2 -2
- package/dist/chunk-lang-rust-0ydx84s1.js +1 -0
- package/dist/{chunk-lang-shell-cgq6q0kj.js → chunk-lang-shell-gq1tmdz4.js} +1 -1
- package/dist/chunk-lang-sql-xt1qyycg.js +2 -0
- package/dist/{chunk-lang-toml-xh45nm68.js → chunk-lang-toml-0hamc5fc.js} +1 -1
- package/dist/chunk-lang-typescript-82xf7z2e.js +1 -0
- package/dist/{chunk-lang-xml-1mh879j9.js → chunk-lang-xml-wf6h5pt2.js} +1 -1
- package/dist/{chunk-lang-yaml-jac98srt.js → chunk-lang-yaml-7vk521h1.js} +1 -1
- package/dist/chunk-layout-actions-zh7bezk7.js +1 -0
- package/dist/chunk-menu-items-1n9qargb.js +2 -0
- package/dist/chunk-notes-t15bh04m.js +1 -0
- package/dist/chunk-notice-bell-w3y0h0sr.js +1 -0
- package/dist/chunk-notices-fa3g7qze.js +1 -0
- package/dist/chunk-onboarding-34v3nr17.js +1 -0
- package/dist/chunk-overview-km5vq375.js +1 -0
- package/dist/chunk-palette-a8txdgd0.js +1 -0
- package/dist/chunk-placement-items-wgaxsbvs.js +1 -0
- package/dist/chunk-profile-ypxdbsk1.js +1 -0
- package/dist/chunk-prompt-history-wm9dsy6s.js +1 -0
- package/dist/chunk-prompts-7mhhq7zs.js +1 -0
- package/dist/chunk-room-confirms-t11a1gsw.js +1 -0
- package/dist/chunk-room-menu-2tm2xtfx.js +1 -0
- package/dist/chunk-rooms-menu-button-wxnsxjwc.js +1 -0
- package/dist/chunk-schedules-2ft6xxyw.js +1 -0
- package/dist/chunk-seat-menu-d3npdtx1.js +1 -0
- package/dist/chunk-sheet-hosts-vngvp7wv.js +1 -0
- package/dist/chunk-skill-editor-65n2hsq4.js +8 -0
- package/dist/chunk-skills-shkdwes8.js +1 -0
- package/dist/chunk-switcher-list-g6hhmr1t.js +1 -0
- package/dist/chunk-table-1b6bsq2y.js +1 -0
- package/dist/chunk-table-seats-vy1qhdks.js +1 -0
- package/dist/chunk-table2d-n26b7gd0.js +1 -0
- package/dist/chunk-table3d-8aysyf56.js +4220 -0
- package/dist/chunk-transcript-355faceq.js +1 -0
- package/dist/chunk-where-j5qdt8a4.js +1 -0
- package/dist/chunk-workspace-vrbs6zx1.js +1 -0
- package/dist/chunk-zh-tw-zeg600bk.js +1 -0
- package/dist/index.html +1 -1
- package/dist/sw.js +1 -1
- package/package.json +5 -4
- package/src/api-types.ts +515 -6
- package/src/app/api-http.ts +66 -0
- package/src/app/app.ts +214 -33
- package/src/app/card-log.ts +6 -0
- package/src/app/desk.ts +47 -22
- package/src/app/files.ts +73 -7
- package/src/app/layout-routes.ts +239 -0
- package/src/app/layout.ts +133 -0
- package/src/app/presence.ts +2 -2
- package/src/app/prompt-routes.ts +167 -0
- package/src/app/protocol.ts +63 -1
- package/src/app/room-log.ts +148 -14
- package/src/app/rooms.ts +23 -0
- package/src/app/routes.ts +278 -156
- package/src/app/skill-doc-routes.ts +122 -0
- package/src/app/socket.ts +309 -77
- package/src/app/sockets.ts +108 -20
- package/src/app/surface.ts +72 -36
- package/src/app/team-routes.ts +457 -0
- package/src/app/threads.ts +14 -0
- package/src/app/uploads.ts +33 -7
- package/src/auth/authenticator.ts +71 -8
- package/src/auth/next.ts +52 -0
- package/src/auth/oidc-pages.ts +60 -0
- package/src/auth/oidc.ts +611 -0
- package/src/auth/sessions.ts +193 -0
- package/src/authz.ts +172 -0
- package/src/console-api.ts +122 -25
- package/src/console-server.ts +70 -6
- package/src/gate.ts +90 -0
- package/src/host/access.ts +161 -0
- package/src/host/cards.ts +99 -0
- package/src/host/context.ts +141 -0
- package/src/host/forms.ts +237 -0
- package/src/host/frames.ts +342 -0
- package/src/host/host-app.ts +176 -0
- package/src/host/host-context.ts +49 -0
- package/src/host/host-routes.ts +363 -0
- package/src/host/pages.ts +266 -0
- package/src/host/reads.ts +155 -0
- package/src/host/retention.ts +41 -0
- package/src/host/room-views.ts +137 -0
- package/src/host/rooms-service.ts +414 -0
- package/src/host/stored-json.ts +13 -0
- package/src/host/views.ts +103 -0
- package/src/i18n/zh-tw.ts +309 -10
- package/src/layout-order.ts +210 -0
- package/src/notices/api.ts +43 -0
- package/src/notices/mount.ts +292 -0
- package/src/notices/service.ts +294 -0
- package/src/notices/store.ts +298 -0
- package/src/notices/urls.ts +38 -0
- package/src/onboarding/api.ts +32 -0
- package/src/onboarding/service.ts +149 -0
- package/src/onboarding/steps.ts +75 -0
- package/src/onboarding/waivers.ts +40 -0
- package/src/options.ts +498 -34
- package/src/profile/api.ts +175 -0
- package/src/profile/links.ts +162 -0
- package/src/push/api.ts +189 -52
- package/src/push/dispatch.ts +156 -0
- package/src/push/drain.ts +139 -0
- package/src/push/mutes.ts +17 -0
- package/src/push/outbox.ts +218 -0
- package/src/push/prefs.ts +118 -0
- package/src/push/sender.ts +7 -2
- package/src/push/triggers.ts +56 -5
- package/src/push/vapid.ts +19 -0
- package/src/push/web-push-transport.ts +106 -0
- package/src/push-sender/index.ts +14 -0
- package/src/push-sender/run.ts +118 -0
- package/src/push-sender/session.ts +54 -0
- package/src/visitors.ts +94 -6
- package/src/web-app.ts +674 -0
- package/src/web-plugin.ts +293 -26
- package/dist/chunk-action-menu-7v5fce2v.js +0 -1
- package/dist/chunk-agent-2rw4ydw2.js +0 -1
- package/dist/chunk-agent-sheet-2q24dkkf.js +0 -3
- package/dist/chunk-character-creator-8q6ympxj.js +0 -1
- package/dist/chunk-codeeditor-fgmq298t.js +0 -2
- package/dist/chunk-connectors-mr53r7fh.js +0 -1
- package/dist/chunk-conversations-4vy2z6w9.js +0 -1
- package/dist/chunk-entry-jg1eem82.js +0 -1
- package/dist/chunk-group-scene-na95b1te.js +0 -1
- package/dist/chunk-index-11rjg8a8.js +0 -1
- package/dist/chunk-index-1y50d6sj.js +0 -2
- package/dist/chunk-index-5y2r0eaj.js +0 -47
- package/dist/chunk-index-7fphxxzd.js +0 -14
- package/dist/chunk-index-80f1zkp6.css +0 -1
- package/dist/chunk-index-87h45frg.js +0 -2
- package/dist/chunk-index-bnwmssyn.js +0 -2
- package/dist/chunk-index-ckgbszwt.js +0 -51
- package/dist/chunk-index-fmjjekhd.js +0 -2
- package/dist/chunk-index-t1m65yn6.js +0 -13
- package/dist/chunk-index-t711t17m.js +0 -2
- package/dist/chunk-index-txv7qvyy.js +0 -2
- package/dist/chunk-index-wr3dg24d.js +0 -2
- package/dist/chunk-index-yx683d5n.js +0 -2
- package/dist/chunk-lang-css-fkdg3x8x.js +0 -1
- package/dist/chunk-lang-html-v8h9g977.js +0 -1
- package/dist/chunk-lang-javascript-ykb2vhaa.js +0 -1
- package/dist/chunk-lang-json-2rze3rpy.js +0 -1
- package/dist/chunk-lang-markdown-6w2htnfc.js +0 -6
- package/dist/chunk-lang-rust-24fggcqx.js +0 -1
- package/dist/chunk-lang-sql-gjywb9ke.js +0 -2
- package/dist/chunk-lang-typescript-qh6rebyb.js +0 -1
- package/dist/chunk-menu-items-zjpyj51b.js +0 -2
- package/dist/chunk-notes-sjba7kw3.js +0 -1
- package/dist/chunk-overview-26r77kb6.js +0 -1
- package/dist/chunk-room-confirms-851184ep.js +0 -1
- package/dist/chunk-room-menu-8y9c026y.js +0 -1
- package/dist/chunk-skills-56s4hdbn.js +0 -1
- package/dist/chunk-table-qykpvp32.js +0 -1
- package/dist/chunk-table2d-k60ressj.js +0 -1
- package/dist/chunk-table3d-bv2ajk39.js +0 -4220
- package/dist/chunk-transcript-vekz1mh6.js +0 -1
- package/dist/chunk-workspace-hevefd0d.js +0 -1
- package/dist/chunk-zh-tw-5r2wjre0.js +0 -1
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { IdentityService, Logger, Tier } from "pi-roundtable";
|
|
2
|
+
import type { NewOutboxRow, OutboxKind, PushOutbox } from "./outbox.ts";
|
|
3
|
+
import type { PushPrefsStore } from "./prefs.ts";
|
|
4
|
+
import { previewOf } from "./triggers.ts";
|
|
5
|
+
|
|
6
|
+
export interface PushDispatchOptions {
|
|
7
|
+
outbox: Pick<PushOutbox, "enqueue">;
|
|
8
|
+
prefs: Pick<PushPrefsStore, "quietAt">;
|
|
9
|
+
identity: Pick<IdentityService, "tierOf" | "speakerFor">;
|
|
10
|
+
/** The tiers whose people receive push. */
|
|
11
|
+
tiers: readonly Tier[];
|
|
12
|
+
/** `full` queues a title (and a preview with `preview`); `none` queues the kind, tag and count only. */
|
|
13
|
+
content: "full" | "none";
|
|
14
|
+
preview: boolean;
|
|
15
|
+
/** Whether a page of the person is in view now. */
|
|
16
|
+
visible(principalId: string): boolean;
|
|
17
|
+
/** The operator's quiet window, which holds back a normal notice; absent = none. */
|
|
18
|
+
quiet?: (at: number) => boolean;
|
|
19
|
+
/** Starts the in-process sender at once; absent when a separate process drains the outbox. */
|
|
20
|
+
kick?: () => void;
|
|
21
|
+
/** Whether push is set up at all (a VAPID contact, or an external sender). */
|
|
22
|
+
enabled: boolean;
|
|
23
|
+
/** The transports the sender has, which a notice's `via` may name. */
|
|
24
|
+
transports: readonly string[];
|
|
25
|
+
logger: Logger;
|
|
26
|
+
/** The clock, in milliseconds; default `Date.now`. */
|
|
27
|
+
now?: () => number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What a notice says when it is queued for a person. */
|
|
31
|
+
export interface NoticePush {
|
|
32
|
+
tag: string;
|
|
33
|
+
title: string;
|
|
34
|
+
body?: string;
|
|
35
|
+
/** The path a tap opens. */
|
|
36
|
+
open: string;
|
|
37
|
+
urgency: "normal" | "high";
|
|
38
|
+
badge: number;
|
|
39
|
+
via?: readonly string[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Why a push was not queued; the notice itself is in the bell all the same. */
|
|
43
|
+
export type Withheld = "off" | "tier" | "visible" | "quiet" | "failed";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Decides whether a notice becomes a push for a person and queues it. A person gets it only when
|
|
47
|
+
* push is set up, their tier may receive it, and (for a normal notice) no page of theirs is in
|
|
48
|
+
* view and it is not their quiet hours or the operator's; a `high` notice ignores the last two.
|
|
49
|
+
* Whatever the content setting, the row carries a title and preview only with `content: "full"`.
|
|
50
|
+
*/
|
|
51
|
+
export class PushDispatch {
|
|
52
|
+
readonly #options: PushDispatchOptions;
|
|
53
|
+
|
|
54
|
+
constructor(options: PushDispatchOptions) {
|
|
55
|
+
this.#options = options;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The transports the sender has; a notice's `via` must name only these. */
|
|
59
|
+
get transports(): readonly string[] {
|
|
60
|
+
return this.#options.transports;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async notice(
|
|
64
|
+
principalId: string,
|
|
65
|
+
notice: NoticePush,
|
|
66
|
+
): Promise<"queued" | Withheld> {
|
|
67
|
+
const { logger } = this.#options;
|
|
68
|
+
try {
|
|
69
|
+
if (!this.#options.enabled) return "off";
|
|
70
|
+
if (!(await this.#mayReceive(principalId))) return "tier";
|
|
71
|
+
if (notice.urgency === "normal") {
|
|
72
|
+
if (this.#options.visible(principalId)) return "visible";
|
|
73
|
+
const at = (this.#options.now ?? Date.now)();
|
|
74
|
+
if (this.#options.quiet?.(at)) return "quiet";
|
|
75
|
+
if (await this.#options.prefs.quietAt(principalId, at)) return "quiet";
|
|
76
|
+
}
|
|
77
|
+
await this.#queue(principalId, "notice", notice);
|
|
78
|
+
return "queued";
|
|
79
|
+
} catch (error) {
|
|
80
|
+
logger.warn({ err: error, tag: notice.tag }, "a notice was not queued");
|
|
81
|
+
return "failed";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Tells the person's devices a notice is gone: closes its notification and sets the count. */
|
|
86
|
+
async clear(
|
|
87
|
+
principalId: string,
|
|
88
|
+
tag: string,
|
|
89
|
+
open: string,
|
|
90
|
+
badge: number,
|
|
91
|
+
): Promise<void> {
|
|
92
|
+
try {
|
|
93
|
+
if (!this.#options.enabled) return;
|
|
94
|
+
await this.#queue(principalId, "clear", {
|
|
95
|
+
tag,
|
|
96
|
+
title: "",
|
|
97
|
+
open,
|
|
98
|
+
urgency: "normal",
|
|
99
|
+
badge,
|
|
100
|
+
});
|
|
101
|
+
} catch (error) {
|
|
102
|
+
this.#options.logger.warn(
|
|
103
|
+
{ err: error, tag },
|
|
104
|
+
"a notice was not cleared on the devices",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A test push to the person's own devices; it is not held back by quiet hours or an open page. */
|
|
110
|
+
async test(principalId: string, tag: string, open: string, badge: number) {
|
|
111
|
+
await this.#queue(principalId, "test", {
|
|
112
|
+
tag,
|
|
113
|
+
title: "Test notification",
|
|
114
|
+
body: "Notifications work on this device.",
|
|
115
|
+
open,
|
|
116
|
+
urgency: "normal",
|
|
117
|
+
badge,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async #mayReceive(principalId: string): Promise<boolean> {
|
|
122
|
+
const { identity, tiers } = this.#options;
|
|
123
|
+
const lasting = await identity.tierOf(principalId);
|
|
124
|
+
if (lasting) return tiers.includes(lasting);
|
|
125
|
+
try {
|
|
126
|
+
return tiers.includes((await identity.speakerFor(principalId)).tier);
|
|
127
|
+
} catch {
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async #queue(
|
|
133
|
+
principalId: string,
|
|
134
|
+
kind: OutboxKind,
|
|
135
|
+
notice: NoticePush,
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
const { content, preview, outbox, kick } = this.#options;
|
|
138
|
+
const row: NewOutboxRow = {
|
|
139
|
+
kind,
|
|
140
|
+
tag: notice.tag,
|
|
141
|
+
principalId,
|
|
142
|
+
open: notice.open,
|
|
143
|
+
urgency: notice.urgency,
|
|
144
|
+
badge: notice.badge,
|
|
145
|
+
...(content === "full" && kind !== "clear"
|
|
146
|
+
? {
|
|
147
|
+
title: notice.title,
|
|
148
|
+
...(preview && notice.body ? { body: previewOf(notice.body) } : {}),
|
|
149
|
+
}
|
|
150
|
+
: {}),
|
|
151
|
+
...(notice.via ? { via: notice.via } : {}),
|
|
152
|
+
};
|
|
153
|
+
await outbox.enqueue(row);
|
|
154
|
+
kick?.();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Logger } from "pi-roundtable";
|
|
2
|
+
import type { DeliveryResult, OutboxRow, PushOutbox } from "./outbox.ts";
|
|
3
|
+
|
|
4
|
+
/** How a transport carries a push; a host adds its own beside `web-push`. */
|
|
5
|
+
export interface PushTransport {
|
|
6
|
+
/** The name a notice's `via` uses, such as `web-push`. */
|
|
7
|
+
name: string;
|
|
8
|
+
send(
|
|
9
|
+
row: OutboxRow,
|
|
10
|
+
rendered: { title: string; body?: string },
|
|
11
|
+
signal: AbortSignal,
|
|
12
|
+
): Promise<DeliveryResult>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface OutboxSenderOptions {
|
|
16
|
+
outbox: Pick<PushOutbox, "claim" | "finish" | "sweep">;
|
|
17
|
+
/** Tried in order for each row until one says `sent`. */
|
|
18
|
+
transports: readonly PushTransport[];
|
|
19
|
+
/** Words a row. With push.content `none` a row carries no text, so this is where it comes from. */
|
|
20
|
+
render(row: OutboxRow): { title: string; body?: string };
|
|
21
|
+
logger: Logger;
|
|
22
|
+
/** How often the outbox is looked at; default 1 s. */
|
|
23
|
+
intervalMs?: number;
|
|
24
|
+
/** How long one transport may take for one row; default 20 s. */
|
|
25
|
+
transportTimeoutMs?: number;
|
|
26
|
+
/** The most rows taken at once; default 20. */
|
|
27
|
+
batch?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SWEEP_EVERY_MS = 3_600_000;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The loop that empties the push outbox: it takes queued rows, words each with `render`, offers it
|
|
34
|
+
* to the row's transports in order until one says `sent`, and records what each said. A transport
|
|
35
|
+
* that throws or runs out of time counts as `failed`; one row never stops the others.
|
|
36
|
+
*/
|
|
37
|
+
export class OutboxSender {
|
|
38
|
+
readonly #options: OutboxSenderOptions;
|
|
39
|
+
#timer: ReturnType<typeof setInterval> | undefined;
|
|
40
|
+
#sweeper: ReturnType<typeof setInterval> | undefined;
|
|
41
|
+
#running: Promise<number> | undefined;
|
|
42
|
+
#stopped = false;
|
|
43
|
+
|
|
44
|
+
constructor(options: OutboxSenderOptions) {
|
|
45
|
+
this.#options = options;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
start(): void {
|
|
49
|
+
this.#stopped = false;
|
|
50
|
+
this.#timer = setInterval(() => {
|
|
51
|
+
this.drain().catch((error: unknown) =>
|
|
52
|
+
this.#options.logger.warn(
|
|
53
|
+
{ err: error },
|
|
54
|
+
"the push outbox was not read",
|
|
55
|
+
),
|
|
56
|
+
);
|
|
57
|
+
}, this.#options.intervalMs ?? 1000);
|
|
58
|
+
this.#sweeper = setInterval(() => {
|
|
59
|
+
this.#options.outbox
|
|
60
|
+
.sweep()
|
|
61
|
+
.catch((error: unknown) =>
|
|
62
|
+
this.#options.logger.warn(
|
|
63
|
+
{ err: error },
|
|
64
|
+
"old pushes were not deleted",
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
}, SWEEP_EVERY_MS);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async stop(): Promise<void> {
|
|
71
|
+
this.#stopped = true;
|
|
72
|
+
clearInterval(this.#timer);
|
|
73
|
+
clearInterval(this.#sweeper);
|
|
74
|
+
await this.#running?.catch(() => undefined);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Takes and sends what is queued now; returns how many rows it finished. Calls do not overlap. */
|
|
78
|
+
drain(): Promise<number> {
|
|
79
|
+
this.#running ??= this.#drain().finally(() => {
|
|
80
|
+
this.#running = undefined;
|
|
81
|
+
});
|
|
82
|
+
return this.#running;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async #drain(): Promise<number> {
|
|
86
|
+
let finished = 0;
|
|
87
|
+
for (;;) {
|
|
88
|
+
if (this.#stopped) return finished;
|
|
89
|
+
const rows = await this.#options.outbox.claim(this.#options.batch ?? 20);
|
|
90
|
+
if (rows.length === 0) return finished;
|
|
91
|
+
for (const row of rows) {
|
|
92
|
+
await this.#one(row);
|
|
93
|
+
finished += 1;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async #one(row: OutboxRow): Promise<void> {
|
|
99
|
+
const { transports, logger } = this.#options;
|
|
100
|
+
const chosen = row.via
|
|
101
|
+
? row.via.flatMap((name) => {
|
|
102
|
+
const found = transports.find((t) => t.name === name);
|
|
103
|
+
return found ? [found] : [];
|
|
104
|
+
})
|
|
105
|
+
: transports;
|
|
106
|
+
const deliveries: { transport: string; result: DeliveryResult }[] = [];
|
|
107
|
+
let rendered: { title: string; body?: string };
|
|
108
|
+
try {
|
|
109
|
+
// A row that clears a notice has no words.
|
|
110
|
+
rendered =
|
|
111
|
+
row.kind === "clear" ? { title: "" } : this.#options.render(row);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
logger.warn({ err: error, kind: row.kind }, "a push was not worded");
|
|
114
|
+
await this.#options.outbox.finish(row.id, [
|
|
115
|
+
{ transport: "render", result: "failed" },
|
|
116
|
+
]);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
for (const transport of chosen) {
|
|
120
|
+
let result: DeliveryResult;
|
|
121
|
+
try {
|
|
122
|
+
result = await transport.send(
|
|
123
|
+
row,
|
|
124
|
+
rendered,
|
|
125
|
+
AbortSignal.timeout(this.#options.transportTimeoutMs ?? 20_000),
|
|
126
|
+
);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
logger.warn(
|
|
129
|
+
{ err: error, transport: transport.name },
|
|
130
|
+
"a push transport failed",
|
|
131
|
+
);
|
|
132
|
+
result = "failed";
|
|
133
|
+
}
|
|
134
|
+
deliveries.push({ transport: transport.name, result });
|
|
135
|
+
if (result === "sent") break;
|
|
136
|
+
}
|
|
137
|
+
await this.#options.outbox.finish(row.id, deliveries);
|
|
138
|
+
}
|
|
139
|
+
}
|
package/src/push/mutes.ts
CHANGED
|
@@ -38,6 +38,23 @@ export class PushMutes {
|
|
|
38
38
|
return new Set(rows.map((row) => row.room));
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/** Mutes or unmutes every room in the list as one step; a room already so is left as it is. */
|
|
42
|
+
async setMany(
|
|
43
|
+
rooms: readonly string[],
|
|
44
|
+
muted: boolean,
|
|
45
|
+
by: string,
|
|
46
|
+
): Promise<void> {
|
|
47
|
+
if (rooms.length === 0) return;
|
|
48
|
+
await this.#sql.begin(async (tx) => {
|
|
49
|
+
if (muted)
|
|
50
|
+
await tx`
|
|
51
|
+
INSERT INTO web_push_muted_rooms ${tx(rooms.map((room) => ({ room, muted_by: by })))}
|
|
52
|
+
ON CONFLICT (room) DO NOTHING`;
|
|
53
|
+
else
|
|
54
|
+
await tx`DELETE FROM web_push_muted_rooms WHERE room IN ${tx(rooms)}`;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
41
58
|
/** Mutes or unmutes a room; doing either twice leaves it as it is. */
|
|
42
59
|
async set(room: string, muted: boolean, by: string): Promise<void> {
|
|
43
60
|
if (muted)
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { Migration } from "pi-roundtable";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a push is about; a sender renders its words from it. A `clear` row has no words: it tells
|
|
6
|
+
* the devices a notice is gone (close its notification, set the count), and a transport that has
|
|
7
|
+
* no such thing skips it.
|
|
8
|
+
*/
|
|
9
|
+
export type OutboxKind = "card" | "reply" | "notice" | "test" | "clear";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One push to one person, queued for a sender. It carries what a sender needs to word and address
|
|
13
|
+
* the push, and no host-room content: `title` and `body` are present only with `push.content`
|
|
14
|
+
* `full`.
|
|
15
|
+
*/
|
|
16
|
+
export interface OutboxRow {
|
|
17
|
+
id: number;
|
|
18
|
+
kind: OutboxKind;
|
|
19
|
+
/** Pushes with one tag replace each other on a device; a notice's tag is its dedupe key. */
|
|
20
|
+
tag: string;
|
|
21
|
+
principalId: string;
|
|
22
|
+
/** Where a tap goes: a path under the app's origin. */
|
|
23
|
+
open: string;
|
|
24
|
+
urgency: "normal" | "high";
|
|
25
|
+
/** The count the app icon shows after this push: the person's notices not handled. */
|
|
26
|
+
badge: number;
|
|
27
|
+
title?: string;
|
|
28
|
+
body?: string;
|
|
29
|
+
/** The transports that may carry the row, in order; absent = all the sender has, in order. */
|
|
30
|
+
via?: readonly string[];
|
|
31
|
+
createdAt: Date;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type NewOutboxRow = Omit<OutboxRow, "id" | "createdAt">;
|
|
35
|
+
|
|
36
|
+
/** What a transport said of one delivery. */
|
|
37
|
+
export type DeliveryResult = "sent" | "gone" | "failed" | "skipped";
|
|
38
|
+
|
|
39
|
+
interface Row {
|
|
40
|
+
id: string | number;
|
|
41
|
+
kind: OutboxKind;
|
|
42
|
+
tag: string;
|
|
43
|
+
principal_id: string;
|
|
44
|
+
open: string;
|
|
45
|
+
urgency: "normal" | "high";
|
|
46
|
+
badge: number;
|
|
47
|
+
title: string | null;
|
|
48
|
+
body: string | null;
|
|
49
|
+
via: string[] | null;
|
|
50
|
+
created_at: Date;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const rowOf = (row: Row): OutboxRow => ({
|
|
54
|
+
id: Number(row.id),
|
|
55
|
+
kind: row.kind,
|
|
56
|
+
tag: row.tag,
|
|
57
|
+
principalId: row.principal_id,
|
|
58
|
+
open: row.open,
|
|
59
|
+
urgency: row.urgency,
|
|
60
|
+
badge: row.badge,
|
|
61
|
+
...(row.title === null ? {} : { title: row.title }),
|
|
62
|
+
...(row.body === null ? {} : { body: row.body }),
|
|
63
|
+
...(row.via === null ? {} : { via: row.via }),
|
|
64
|
+
createdAt: row.created_at,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/** A claimed row that no sender finished is claimed again after this long. */
|
|
68
|
+
const CLAIM_LEASE_SECONDS = 60;
|
|
69
|
+
/** Finished rows, deliveries and receipts are kept this long, in days. */
|
|
70
|
+
export const OUTBOX_KEEP_DAYS = 30;
|
|
71
|
+
/** A device that returned a receipt this recently counts as working. */
|
|
72
|
+
export const RECEIPT_WINDOW_DAYS = 30;
|
|
73
|
+
|
|
74
|
+
/** A receipt the service worker posted: the notification arrived, or was opened. */
|
|
75
|
+
export type ReceiptEvent = "received" | "opened";
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The queue between the app and the process that signs and sends pushes (`web_push_outbox`), what
|
|
79
|
+
* the sender did with each row (`web_push_deliveries`), and the receipts the service worker posts
|
|
80
|
+
* back (`web_push_receipts`). The app and an external sender both use it: the sender needs only to
|
|
81
|
+
* read and update the outbox and insert deliveries.
|
|
82
|
+
*/
|
|
83
|
+
export class PushOutbox {
|
|
84
|
+
readonly #sql: SQL;
|
|
85
|
+
|
|
86
|
+
constructor(sql: SQL) {
|
|
87
|
+
this.#sql = sql;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
static readonly migration: Migration = {
|
|
91
|
+
name: "web-push-outbox",
|
|
92
|
+
up: async (sql) => {
|
|
93
|
+
await sql`
|
|
94
|
+
CREATE TABLE IF NOT EXISTS web_push_outbox (
|
|
95
|
+
id bigserial PRIMARY KEY,
|
|
96
|
+
kind text NOT NULL,
|
|
97
|
+
tag text NOT NULL,
|
|
98
|
+
principal_id text NOT NULL,
|
|
99
|
+
open text NOT NULL,
|
|
100
|
+
urgency text NOT NULL,
|
|
101
|
+
badge integer NOT NULL DEFAULT 0,
|
|
102
|
+
title text,
|
|
103
|
+
body text,
|
|
104
|
+
via text[],
|
|
105
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
106
|
+
claimed_at timestamptz,
|
|
107
|
+
done_at timestamptz
|
|
108
|
+
)`;
|
|
109
|
+
await sql`CREATE INDEX IF NOT EXISTS web_push_outbox_open ON web_push_outbox (id) WHERE done_at IS NULL`;
|
|
110
|
+
await sql`CREATE INDEX IF NOT EXISTS web_push_outbox_done ON web_push_outbox (done_at)`;
|
|
111
|
+
await sql`
|
|
112
|
+
CREATE TABLE IF NOT EXISTS web_push_deliveries (
|
|
113
|
+
outbox_id bigint NOT NULL,
|
|
114
|
+
transport text NOT NULL,
|
|
115
|
+
endpoint_hash text,
|
|
116
|
+
result text NOT NULL,
|
|
117
|
+
at timestamptz NOT NULL DEFAULT now()
|
|
118
|
+
)`;
|
|
119
|
+
await sql`CREATE INDEX IF NOT EXISTS web_push_deliveries_outbox ON web_push_deliveries (outbox_id)`;
|
|
120
|
+
await sql`
|
|
121
|
+
CREATE TABLE IF NOT EXISTS web_push_receipts (
|
|
122
|
+
principal_id text NOT NULL,
|
|
123
|
+
tag text NOT NULL,
|
|
124
|
+
event text NOT NULL,
|
|
125
|
+
at timestamptz NOT NULL DEFAULT now()
|
|
126
|
+
)`;
|
|
127
|
+
await sql`CREATE INDEX IF NOT EXISTS web_push_receipts_principal ON web_push_receipts (principal_id, at DESC)`;
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/** Queues a push; returns its id. */
|
|
132
|
+
async enqueue(row: NewOutboxRow): Promise<number> {
|
|
133
|
+
const [made]: { id: string | number }[] = await this.#sql`
|
|
134
|
+
INSERT INTO web_push_outbox (kind, tag, principal_id, open, urgency, badge, title, body, via)
|
|
135
|
+
VALUES (${row.kind}, ${row.tag}, ${row.principalId}, ${row.open}, ${row.urgency},
|
|
136
|
+
${row.badge}, ${row.title ?? null}, ${row.body ?? null},
|
|
137
|
+
${row.via ? this.#sql.array([...row.via], "TEXT") : null})
|
|
138
|
+
RETURNING id`;
|
|
139
|
+
if (!made) throw new Error("the push outbox did not return an id");
|
|
140
|
+
return Number(made.id);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Takes up to `limit` rows no sender holds, oldest first; a row held longer than the lease is taken again. */
|
|
144
|
+
async claim(limit: number): Promise<OutboxRow[]> {
|
|
145
|
+
const rows: Row[] = await this.#sql`
|
|
146
|
+
UPDATE web_push_outbox SET claimed_at = now()
|
|
147
|
+
WHERE id IN (
|
|
148
|
+
SELECT id FROM web_push_outbox
|
|
149
|
+
WHERE done_at IS NULL
|
|
150
|
+
AND (claimed_at IS NULL OR claimed_at < now() - make_interval(secs => ${CLAIM_LEASE_SECONDS}))
|
|
151
|
+
ORDER BY id LIMIT ${limit}
|
|
152
|
+
FOR UPDATE SKIP LOCKED)
|
|
153
|
+
RETURNING *`;
|
|
154
|
+
return rows.map(rowOf).sort((a, b) => a.id - b.id);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Records what the transports said of a row and finishes it. */
|
|
158
|
+
async finish(
|
|
159
|
+
id: number,
|
|
160
|
+
deliveries: readonly { transport: string; result: DeliveryResult }[],
|
|
161
|
+
): Promise<void> {
|
|
162
|
+
await this.#sql.begin(async (tx) => {
|
|
163
|
+
for (const delivery of deliveries)
|
|
164
|
+
await tx`
|
|
165
|
+
INSERT INTO web_push_deliveries (outbox_id, transport, result)
|
|
166
|
+
VALUES (${id}, ${delivery.transport}, ${delivery.result})`;
|
|
167
|
+
await tx`UPDATE web_push_outbox SET done_at = now() WHERE id = ${id}`;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Whether the person has a queued push of this kind and tag, which makes a receipt for it theirs. */
|
|
172
|
+
async has(
|
|
173
|
+
principalId: string,
|
|
174
|
+
kind: OutboxKind,
|
|
175
|
+
tag: string,
|
|
176
|
+
): Promise<boolean> {
|
|
177
|
+
const [found]: { n: number }[] = await this.#sql`
|
|
178
|
+
SELECT count(*)::int AS n FROM web_push_outbox
|
|
179
|
+
WHERE principal_id = ${principalId} AND kind = ${kind} AND tag = ${tag}`;
|
|
180
|
+
return (found?.n ?? 0) > 0;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async receipt(
|
|
184
|
+
principalId: string,
|
|
185
|
+
tag: string,
|
|
186
|
+
event: ReceiptEvent,
|
|
187
|
+
): Promise<void> {
|
|
188
|
+
await this.#sql`
|
|
189
|
+
INSERT INTO web_push_receipts (principal_id, tag, event)
|
|
190
|
+
VALUES (${principalId}, ${tag}, ${event})`;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** When a device of the person last posted a receipt, within the window; undefined for none. */
|
|
194
|
+
async lastReceiptAt(
|
|
195
|
+
principalId: string,
|
|
196
|
+
withinDays = RECEIPT_WINDOW_DAYS,
|
|
197
|
+
): Promise<Date | undefined> {
|
|
198
|
+
const [found]: { at: Date | null }[] = await this.#sql`
|
|
199
|
+
SELECT max(at) AS at FROM web_push_receipts
|
|
200
|
+
WHERE principal_id = ${principalId}
|
|
201
|
+
AND at > now() - make_interval(days => ${withinDays})`;
|
|
202
|
+
return found?.at ?? undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Deletes finished rows, their deliveries and old receipts; returns how many rows went. */
|
|
206
|
+
async sweep(): Promise<number> {
|
|
207
|
+
const gone: { id: string | number }[] = await this.#sql`
|
|
208
|
+
DELETE FROM web_push_outbox
|
|
209
|
+
WHERE done_at < now() - make_interval(days => ${OUTBOX_KEEP_DAYS}) RETURNING id`;
|
|
210
|
+
await this.#sql`
|
|
211
|
+
DELETE FROM web_push_deliveries d WHERE NOT EXISTS (
|
|
212
|
+
SELECT 1 FROM web_push_outbox o WHERE o.id = d.outbox_id)`;
|
|
213
|
+
await this.#sql`
|
|
214
|
+
DELETE FROM web_push_receipts
|
|
215
|
+
WHERE at < now() - make_interval(days => ${OUTBOX_KEEP_DAYS})`;
|
|
216
|
+
return gone.length;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { SQL } from "bun";
|
|
2
|
+
import type { Migration } from "pi-roundtable";
|
|
3
|
+
import { minutesOfDay, QuietClock, type QuietWindow } from "./quiet-hours.ts";
|
|
4
|
+
|
|
5
|
+
/** A person's own quiet hours: no push for a normal notice in the window, on their time zone's clock. */
|
|
6
|
+
export interface PushPrefs {
|
|
7
|
+
/** `HH:MM`, where the window begins. */
|
|
8
|
+
quietStart: string;
|
|
9
|
+
/** `HH:MM`, where it ends; before `start` when it crosses midnight. */
|
|
10
|
+
quietEnd: string;
|
|
11
|
+
/** The IANA zone the times are read in. */
|
|
12
|
+
timeZone: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Row {
|
|
16
|
+
quiet_start: string;
|
|
17
|
+
quiet_end: string;
|
|
18
|
+
time_zone: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Why a preference was refused, in words the page shows. */
|
|
22
|
+
export class PrefsRefused extends Error {}
|
|
23
|
+
|
|
24
|
+
/** Checks the three fields; throws `PrefsRefused` for a time that is not `HH:MM`, equal times or an unknown zone. */
|
|
25
|
+
export function checkPrefs(value: unknown): PushPrefs {
|
|
26
|
+
const given = value as Partial<PushPrefs> | null;
|
|
27
|
+
if (
|
|
28
|
+
typeof given !== "object" ||
|
|
29
|
+
given === null ||
|
|
30
|
+
typeof given.quietStart !== "string" ||
|
|
31
|
+
typeof given.quietEnd !== "string" ||
|
|
32
|
+
typeof given.timeZone !== "string"
|
|
33
|
+
)
|
|
34
|
+
throw new PrefsRefused(
|
|
35
|
+
"Quiet hours need quietStart, quietEnd and timeZone.",
|
|
36
|
+
);
|
|
37
|
+
if (
|
|
38
|
+
minutesOfDay(given.quietStart) === undefined ||
|
|
39
|
+
minutesOfDay(given.quietEnd) === undefined
|
|
40
|
+
)
|
|
41
|
+
throw new PrefsRefused("Quiet hours are times like 22:00 and 07:30.");
|
|
42
|
+
if (given.quietStart === given.quietEnd)
|
|
43
|
+
throw new PrefsRefused("The two times of the quiet hours must differ.");
|
|
44
|
+
try {
|
|
45
|
+
new QuietClock(
|
|
46
|
+
{ start: given.quietStart, end: given.quietEnd },
|
|
47
|
+
given.timeZone,
|
|
48
|
+
);
|
|
49
|
+
} catch {
|
|
50
|
+
throw new PrefsRefused("That time zone is not known.");
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
quietStart: given.quietStart,
|
|
54
|
+
quietEnd: given.quietEnd,
|
|
55
|
+
timeZone: given.timeZone,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The people's quiet hours, in `web_push_prefs`. */
|
|
60
|
+
export class PushPrefsStore {
|
|
61
|
+
readonly #sql: SQL;
|
|
62
|
+
|
|
63
|
+
constructor(sql: SQL) {
|
|
64
|
+
this.#sql = sql;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
static readonly migration: Migration = {
|
|
68
|
+
name: "web-push-prefs",
|
|
69
|
+
up: async (sql) => {
|
|
70
|
+
await sql`
|
|
71
|
+
CREATE TABLE IF NOT EXISTS web_push_prefs (
|
|
72
|
+
principal_id text PRIMARY KEY,
|
|
73
|
+
quiet_start text NOT NULL,
|
|
74
|
+
quiet_end text NOT NULL,
|
|
75
|
+
time_zone text NOT NULL
|
|
76
|
+
)`;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
async get(principalId: string): Promise<PushPrefs | undefined> {
|
|
81
|
+
const [row]: Row[] = await this.#sql`
|
|
82
|
+
SELECT quiet_start, quiet_end, time_zone FROM web_push_prefs
|
|
83
|
+
WHERE principal_id = ${principalId}`;
|
|
84
|
+
return row
|
|
85
|
+
? {
|
|
86
|
+
quietStart: row.quiet_start,
|
|
87
|
+
quietEnd: row.quiet_end,
|
|
88
|
+
timeZone: row.time_zone,
|
|
89
|
+
}
|
|
90
|
+
: undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Stores the person's quiet hours, or removes them with `undefined`. */
|
|
94
|
+
async set(principalId: string, prefs: PushPrefs | undefined): Promise<void> {
|
|
95
|
+
if (!prefs) {
|
|
96
|
+
await this
|
|
97
|
+
.#sql`DELETE FROM web_push_prefs WHERE principal_id = ${principalId}`;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
await this.#sql`
|
|
101
|
+
INSERT INTO web_push_prefs (principal_id, quiet_start, quiet_end, time_zone)
|
|
102
|
+
VALUES (${principalId}, ${prefs.quietStart}, ${prefs.quietEnd}, ${prefs.timeZone})
|
|
103
|
+
ON CONFLICT (principal_id) DO UPDATE SET
|
|
104
|
+
quiet_start = EXCLUDED.quiet_start, quiet_end = EXCLUDED.quiet_end,
|
|
105
|
+
time_zone = EXCLUDED.time_zone`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Whether the moment is inside the person's quiet hours; false when they set none. */
|
|
109
|
+
async quietAt(principalId: string, at: number): Promise<boolean> {
|
|
110
|
+
const prefs = await this.get(principalId);
|
|
111
|
+
if (!prefs) return false;
|
|
112
|
+
const window: QuietWindow = {
|
|
113
|
+
start: prefs.quietStart,
|
|
114
|
+
end: prefs.quietEnd,
|
|
115
|
+
};
|
|
116
|
+
return new QuietClock(window, prefs.timeZone).quietAt(at);
|
|
117
|
+
}
|
|
118
|
+
}
|
package/src/push/sender.ts
CHANGED
|
@@ -18,7 +18,11 @@ export interface PushPayload {
|
|
|
18
18
|
url?: string;
|
|
19
19
|
/** Notifications with one tag replace each other on the device. */
|
|
20
20
|
tag: string;
|
|
21
|
-
kind: "card" | "cap" | "reply" | "notice";
|
|
21
|
+
kind: "card" | "cap" | "reply" | "notice" | "test" | "clear";
|
|
22
|
+
/** The count the app icon shows after this push; absent = leave it. */
|
|
23
|
+
badge?: number;
|
|
24
|
+
/** `high` asks the device to keep the notification until it is dealt with. */
|
|
25
|
+
urgency?: "normal" | "high";
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
/** The part of a stored subscription the sender needs. */
|
|
@@ -83,7 +87,8 @@ export class PushSender {
|
|
|
83
87
|
"content-encoding": "aes128gcm",
|
|
84
88
|
"content-type": "application/octet-stream",
|
|
85
89
|
ttl: String(TTL_SECONDS),
|
|
86
|
-
urgency:
|
|
90
|
+
urgency:
|
|
91
|
+
payload.urgency ?? (payload.kind === "reply" ? "normal" : "high"),
|
|
87
92
|
},
|
|
88
93
|
body,
|
|
89
94
|
// A push service never redirects; a redirect would walk past the allowlist.
|