pi-roundtable-web 0.22.5 → 0.23.1
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 +53 -1
- package/README.md +444 -104
- package/assets/icons/192.png +0 -0
- package/assets/icons/512.png +0 -0
- package/assets/icons/apple-180.png +0 -0
- package/assets/icons/badge-96.png +0 -0
- package/assets/icons/mark-maskable.svg +1 -0
- package/assets/icons/mark.svg +1 -0
- package/assets/icons/maskable-512.png +0 -0
- package/dist/chunk-character-creator-660d2t6p.js +1 -0
- package/dist/chunk-connectors-d19v8cjc.js +1 -0
- package/dist/chunk-conversations-4cmexmqq.js +1 -0
- package/dist/chunk-entry-8xpx35ef.js +1 -0
- package/dist/chunk-index-19g8jmr8.js +2 -0
- package/dist/chunk-index-362dfedj.js +1 -0
- package/dist/chunk-index-6g69tqt0.js +47 -0
- package/dist/chunk-index-89q2qdkj.css +1 -0
- package/dist/chunk-index-9tvf6es7.js +41 -0
- package/dist/chunk-index-p8h0e861.js +2 -0
- package/dist/chunk-index-xw5hhmfq.js +2 -0
- package/dist/chunk-notes-fefwds14.js +1 -0
- package/dist/chunk-overview-bxpwpmn6.js +1 -0
- package/dist/chunk-skills-pce3zgxy.js +1 -0
- package/dist/chunk-table-zmpbrfvz.js +1 -0
- package/dist/chunk-table2d-ac2gez71.js +1 -0
- package/dist/chunk-table3d-g258jemr.js +4229 -0
- package/dist/chunk-transcript-yhtjp3pd.js +1 -0
- package/dist/chunk-zh-tw-anqhqpaz.js +1 -0
- package/dist/index.html +6 -2
- package/dist/sw.js +1 -0
- package/package.json +8 -3
- package/src/api-types.ts +448 -1
- package/src/app/app.ts +351 -0
- package/src/app/card-log.ts +119 -0
- package/src/app/desk.ts +533 -0
- package/src/app/files.ts +262 -0
- package/src/app/frames.ts +132 -0
- package/src/app/multipart.ts +172 -0
- package/src/app/presence.ts +73 -0
- package/src/app/protocol.ts +144 -0
- package/src/app/room-log.ts +137 -0
- package/src/app/rooms.ts +189 -0
- package/src/app/routes.ts +434 -0
- package/src/app/socket.ts +406 -0
- package/src/app/sockets.ts +84 -0
- package/src/app/surface-threads.ts +92 -0
- package/src/app/surface.ts +384 -0
- package/src/app/threads.ts +99 -0
- package/src/app/uploads.ts +363 -0
- package/src/assets.ts +1 -3
- package/src/auth/authenticator.ts +167 -0
- package/src/auth/login-page.ts +44 -0
- package/src/auth/none.ts +38 -0
- package/src/auth/password.ts +250 -0
- package/src/auth/session-key.ts +65 -0
- package/src/cleanup-api.ts +1 -1
- package/src/cloudflare-access.ts +5 -2
- package/src/console-api.ts +13 -15
- package/src/console-server.ts +77 -49
- package/src/features.ts +6 -1
- package/src/http.ts +8 -0
- package/src/i18n/index.ts +13 -0
- package/src/i18n/locale.ts +11 -0
- package/src/i18n/zh-tw.ts +696 -0
- package/src/index.ts +1 -1
- package/src/options.ts +308 -12
- package/src/page-colors.ts +9 -0
- package/src/page-presentation.ts +17 -12
- package/src/push/api.ts +190 -0
- package/src/push/channel.ts +50 -0
- package/src/push/encrypt.ts +136 -0
- package/src/push/endpoint.ts +54 -0
- package/src/push/sender.ts +109 -0
- package/src/push/subscriptions.ts +140 -0
- package/src/push/test-fetch.ts +17 -0
- package/src/push/triggers.ts +155 -0
- package/src/push/vapid.ts +124 -0
- package/src/pwa.ts +130 -0
- package/src/request-body.ts +20 -0
- package/src/verifier.ts +7 -1
- package/src/web-plugin.ts +123 -11
- package/dist/chunk-82frzte9.js +0 -36
- package/dist/chunk-e8z9q554.css +0 -1
package/src/app/desk.ts
ADDED
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ActorFacts,
|
|
3
|
+
type Approval,
|
|
4
|
+
type ApprovedBy,
|
|
5
|
+
type ChannelKey,
|
|
6
|
+
checkAnswer,
|
|
7
|
+
checkQuestionnaireAnswer,
|
|
8
|
+
type LateAnswer,
|
|
9
|
+
type Logger,
|
|
10
|
+
type OwnerAnswer,
|
|
11
|
+
type PromptScope,
|
|
12
|
+
type Prompts,
|
|
13
|
+
QUESTIONNAIRE_LIMITS,
|
|
14
|
+
type QuestionnaireAnswer,
|
|
15
|
+
SYSTEM_PRINCIPAL,
|
|
16
|
+
TIERS,
|
|
17
|
+
type Tier,
|
|
18
|
+
withdrawalNote,
|
|
19
|
+
} from "pi-roundtable";
|
|
20
|
+
import type {
|
|
21
|
+
CardAnswer,
|
|
22
|
+
ClosedCard,
|
|
23
|
+
PromptAnswer,
|
|
24
|
+
PromptFrame,
|
|
25
|
+
PromptOutcome,
|
|
26
|
+
RefusalCode,
|
|
27
|
+
ServerFrame,
|
|
28
|
+
} from "../api-types.ts";
|
|
29
|
+
import {
|
|
30
|
+
askFrame,
|
|
31
|
+
boundedApproval,
|
|
32
|
+
questionnaireFrame,
|
|
33
|
+
wordingFrame,
|
|
34
|
+
} from "./frames.ts";
|
|
35
|
+
|
|
36
|
+
/** How long a card stays open when no one answers it: 24 hours, as a held action does. */
|
|
37
|
+
const CARD_TTL_MS = 24 * 3_600_000;
|
|
38
|
+
|
|
39
|
+
/** What the app's cards show of a questionnaire: every question, previews and a note. */
|
|
40
|
+
const APP_ASKS = Object.freeze({
|
|
41
|
+
questions: QUESTIONNAIRE_LIMITS.questions,
|
|
42
|
+
previews: true,
|
|
43
|
+
note: true,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/** Who answers: an owner visitor, at their tier as of the answer. */
|
|
47
|
+
export interface Answerer {
|
|
48
|
+
principalId: string;
|
|
49
|
+
tier: Tier;
|
|
50
|
+
/** The identity they spoke as, `<provider>:<subject>`. */
|
|
51
|
+
speakerId: string;
|
|
52
|
+
name: string;
|
|
53
|
+
/** The actor their credential vouched for, which a message they start is written as. */
|
|
54
|
+
actor?: ActorFacts;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface OwnerDeskOptions {
|
|
58
|
+
/** Sends a frame to every open socket of every owner. */
|
|
59
|
+
broadcast(frame: ServerFrame): void;
|
|
60
|
+
/** How long a turn that can be resumed waits on its card before it goes on without the answer. */
|
|
61
|
+
graceMs: number;
|
|
62
|
+
/** A card nobody answers closes after this long; default 24 hours. */
|
|
63
|
+
ttlMs?: number;
|
|
64
|
+
/**
|
|
65
|
+
* Starts a turn in a room with a late answer's text, as a message from whoever answered. A
|
|
66
|
+
* rejection is logged.
|
|
67
|
+
*/
|
|
68
|
+
resume(room: ChannelKey, by: Answerer, text: string): void | Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* Keeps a card that ended, for the room's history; called once per card, as it closes. A throw
|
|
71
|
+
* or a rejection is logged and never reaches the turn.
|
|
72
|
+
*/
|
|
73
|
+
closed?(card: ClosedCard): void | Promise<void>;
|
|
74
|
+
/** Hears of a card when it opens, once; the late re-send of a card does not call it again. Never throws into the turn. */
|
|
75
|
+
opened?(room: ChannelKey, frame: PromptFrame): void;
|
|
76
|
+
/** Display names of principals, for a card that names who may approve it. */
|
|
77
|
+
namesOf(principalIds: readonly string[]): Promise<string[]>;
|
|
78
|
+
logger: Logger;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** What an answer, once checked, says. */
|
|
82
|
+
type Ended =
|
|
83
|
+
| { type: "approval"; approved: boolean }
|
|
84
|
+
| { type: "answer"; answer: OwnerAnswer }
|
|
85
|
+
| { type: "answers"; answer: QuestionnaireAnswer }
|
|
86
|
+
/** The question card's Cancel. */
|
|
87
|
+
| { type: "cancel" };
|
|
88
|
+
|
|
89
|
+
interface Open {
|
|
90
|
+
room: ChannelKey;
|
|
91
|
+
frame: PromptFrame;
|
|
92
|
+
/** Whether this person may answer the card. */
|
|
93
|
+
admits(from: Answerer): boolean;
|
|
94
|
+
end(ended: Ended, by: Answerer): void;
|
|
95
|
+
/** Ends the card without an answer, as a stopped turn does. */
|
|
96
|
+
abandon(): void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** What a card gives the turn that waits on it, and what a late answer says to a new turn. */
|
|
100
|
+
interface Card<T> {
|
|
101
|
+
room: ChannelKey;
|
|
102
|
+
frame: PromptFrame;
|
|
103
|
+
signal: AbortSignal | undefined;
|
|
104
|
+
supersede: AbortSignal | undefined;
|
|
105
|
+
/** Present when the turn may stop waiting: what it is told then. */
|
|
106
|
+
pending?: T;
|
|
107
|
+
cancelled: T;
|
|
108
|
+
expired: T;
|
|
109
|
+
admits(from: Answerer): boolean;
|
|
110
|
+
/** The turn's result for an answer given while it still waits. */
|
|
111
|
+
valueOf(ended: Ended): T;
|
|
112
|
+
/** A hook before an approval resolves `approved`. */
|
|
113
|
+
approved?(by: Answerer, prompt: string): void;
|
|
114
|
+
/** The text a late answer starts a turn with; undefined starts none. */
|
|
115
|
+
lateText?(ended: Ended, by: Answerer, prompt: string): string | undefined;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const atLeast = (tier: Tier, least: Tier) =>
|
|
119
|
+
TIERS.indexOf(tier) >= TIERS.indexOf(least);
|
|
120
|
+
|
|
121
|
+
const outcomeOf = (ended: Ended): PromptOutcome => {
|
|
122
|
+
if (ended.type === "approval")
|
|
123
|
+
return ended.approved ? "approved" : "declined";
|
|
124
|
+
return ended.type === "cancel" ? "declined" : "answered";
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
/** What a question card was answered, in the shape the history keeps. */
|
|
128
|
+
function answerOf(ended: Ended): CardAnswer | undefined {
|
|
129
|
+
if (ended.type === "answer") return { kind: "ask", answer: ended.answer };
|
|
130
|
+
if (ended.type === "answers")
|
|
131
|
+
return {
|
|
132
|
+
kind: "questionnaire",
|
|
133
|
+
answers: ended.answer.answers.map((one) => one ?? null),
|
|
134
|
+
...(ended.answer.note ? { note: ended.answer.note } : {}),
|
|
135
|
+
};
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The cards open in the app's rooms: approvals of held actions and questions the turn asks. Every
|
|
141
|
+
* card goes to every socket of every owner, survives a reconnect (each `ready` sends the open
|
|
142
|
+
* ones again), and closes when answered, when its turn stops, when the plugin withdraws it, or
|
|
143
|
+
* after a day. A turn that can be resumed stops waiting after the grace period while the card
|
|
144
|
+
* stays open (`late`); answered then, the card starts a turn in its room with the answer.
|
|
145
|
+
*
|
|
146
|
+
* Who answers: any owner, except a card that is the speaker's alone (`speakerOnly`), which only
|
|
147
|
+
* the speaker's principal answers, and a card in a private conversation, which only its person
|
|
148
|
+
* does. An approval is answered at the tier it needs, read again when the answer comes.
|
|
149
|
+
*/
|
|
150
|
+
export class OwnerDesk {
|
|
151
|
+
readonly #open = new Map<string, Open>();
|
|
152
|
+
readonly #options: OwnerDeskOptions;
|
|
153
|
+
|
|
154
|
+
constructor(options: OwnerDeskOptions) {
|
|
155
|
+
this.#options = options;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The prompts of a room for the scope's turn. */
|
|
159
|
+
prompts(room: ChannelKey, scope?: PromptScope): Prompts {
|
|
160
|
+
const { namesOf } = this.#options;
|
|
161
|
+
return {
|
|
162
|
+
confirm: async (
|
|
163
|
+
title,
|
|
164
|
+
message,
|
|
165
|
+
signal,
|
|
166
|
+
minTier = "owner",
|
|
167
|
+
wait,
|
|
168
|
+
details,
|
|
169
|
+
options,
|
|
170
|
+
) => {
|
|
171
|
+
const speakerOnly = options?.speakerOnly === true;
|
|
172
|
+
const audience = this.#approvalAudience(
|
|
173
|
+
scope,
|
|
174
|
+
minTier,
|
|
175
|
+
options?.approvers,
|
|
176
|
+
speakerOnly,
|
|
177
|
+
);
|
|
178
|
+
if (!audience) return "unavailable";
|
|
179
|
+
const approvers = options?.approvers?.length
|
|
180
|
+
? await namesOf(options.approvers)
|
|
181
|
+
: undefined;
|
|
182
|
+
const id = crypto.randomUUID();
|
|
183
|
+
return this.#post<Approval>({
|
|
184
|
+
room,
|
|
185
|
+
frame: {
|
|
186
|
+
id,
|
|
187
|
+
kind: "approval",
|
|
188
|
+
title,
|
|
189
|
+
message,
|
|
190
|
+
purpose: options?.purpose ?? "action",
|
|
191
|
+
...(details ? { approval: boundedApproval(details) } : {}),
|
|
192
|
+
...(options?.wording
|
|
193
|
+
? { wording: wordingFrame(options.wording) }
|
|
194
|
+
: {}),
|
|
195
|
+
...(speakerOnly ? { speakerOnly: true as const } : {}),
|
|
196
|
+
...(approvers ? { approvers } : {}),
|
|
197
|
+
},
|
|
198
|
+
signal,
|
|
199
|
+
supersede: options?.supersede,
|
|
200
|
+
...(wait ? { pending: "pending" as const } : {}),
|
|
201
|
+
cancelled: "cancelled",
|
|
202
|
+
expired: "expired",
|
|
203
|
+
admits: audience,
|
|
204
|
+
valueOf: (ended) =>
|
|
205
|
+
ended.type === "approval" && ended.approved
|
|
206
|
+
? "approved"
|
|
207
|
+
: "declined",
|
|
208
|
+
approved: (by, prompt) => {
|
|
209
|
+
options?.approvedBy?.(approvedBy(by));
|
|
210
|
+
options?.approvedPrompt?.(prompt);
|
|
211
|
+
},
|
|
212
|
+
lateText: (ended, by, prompt) =>
|
|
213
|
+
ended.type === "approval"
|
|
214
|
+
? wait?.late({
|
|
215
|
+
kind: "approval",
|
|
216
|
+
approved: ended.approved,
|
|
217
|
+
by: by.name,
|
|
218
|
+
approvedBy: approvedBy(by),
|
|
219
|
+
prompt,
|
|
220
|
+
})
|
|
221
|
+
: undefined,
|
|
222
|
+
});
|
|
223
|
+
},
|
|
224
|
+
ask: async (title, question, signal, wait) => {
|
|
225
|
+
const id = crypto.randomUUID();
|
|
226
|
+
return this.#post<OwnerAnswer | "pending" | "declined" | undefined>({
|
|
227
|
+
room,
|
|
228
|
+
frame: askFrame(id, title, question),
|
|
229
|
+
signal,
|
|
230
|
+
supersede: undefined,
|
|
231
|
+
...(wait ? { pending: "pending" as const } : {}),
|
|
232
|
+
cancelled: undefined,
|
|
233
|
+
expired: undefined,
|
|
234
|
+
admits: this.#questionAudience(scope),
|
|
235
|
+
valueOf: (ended) =>
|
|
236
|
+
ended.type === "answer" ? ended.answer : "declined",
|
|
237
|
+
lateText: (ended, by) =>
|
|
238
|
+
ended.type === "answer"
|
|
239
|
+
? wait?.late({
|
|
240
|
+
kind: "question",
|
|
241
|
+
answer: ended.answer,
|
|
242
|
+
by: by.name,
|
|
243
|
+
})
|
|
244
|
+
: undefined,
|
|
245
|
+
});
|
|
246
|
+
},
|
|
247
|
+
asks: APP_ASKS,
|
|
248
|
+
questionnaire: async (title, questionnaire, signal, wait) => {
|
|
249
|
+
const id = crypto.randomUUID();
|
|
250
|
+
return this.#post<
|
|
251
|
+
QuestionnaireAnswer | "pending" | "declined" | undefined
|
|
252
|
+
>({
|
|
253
|
+
room,
|
|
254
|
+
frame: questionnaireFrame(id, title, questionnaire),
|
|
255
|
+
signal,
|
|
256
|
+
supersede: undefined,
|
|
257
|
+
...(wait ? { pending: "pending" as const } : {}),
|
|
258
|
+
cancelled: undefined,
|
|
259
|
+
expired: undefined,
|
|
260
|
+
admits: this.#questionAudience(scope),
|
|
261
|
+
valueOf: (ended) =>
|
|
262
|
+
ended.type === "answers" ? ended.answer : "declined",
|
|
263
|
+
lateText: (ended, by) =>
|
|
264
|
+
ended.type === "answers"
|
|
265
|
+
? wait?.late({
|
|
266
|
+
kind: "questionnaire",
|
|
267
|
+
answer: ended.answer,
|
|
268
|
+
by: by.name,
|
|
269
|
+
})
|
|
270
|
+
: undefined,
|
|
271
|
+
});
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The open cards as frames, to send again when a socket opens. */
|
|
277
|
+
openFrames(): ServerFrame[] {
|
|
278
|
+
return [...this.#open.values()].map((open) => ({
|
|
279
|
+
type: "prompt",
|
|
280
|
+
room: open.room,
|
|
281
|
+
prompt: open.frame,
|
|
282
|
+
}));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Whether the card is open and past its grace, so an answer to it starts a turn. */
|
|
286
|
+
isLate(prompt: string): boolean {
|
|
287
|
+
const frame = this.#open.get(prompt)?.frame;
|
|
288
|
+
return frame !== undefined && "late" in frame && frame.late === true;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Approves or declines; a code when the card is not an approval this person may answer. */
|
|
292
|
+
approve(
|
|
293
|
+
from: Answerer,
|
|
294
|
+
prompt: string,
|
|
295
|
+
approved: boolean,
|
|
296
|
+
): RefusalCode | undefined {
|
|
297
|
+
const open = this.#open.get(prompt);
|
|
298
|
+
if (open?.frame.kind !== "approval") return "unknown_prompt";
|
|
299
|
+
if (!open.admits(from)) return "forbidden";
|
|
300
|
+
open.end({ type: "approval", approved }, from);
|
|
301
|
+
return undefined;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Answers a question card; a code when the answer does not fit it or this person may not answer. */
|
|
305
|
+
answer(
|
|
306
|
+
from: Answerer,
|
|
307
|
+
prompt: string,
|
|
308
|
+
answer: PromptAnswer,
|
|
309
|
+
): RefusalCode | undefined {
|
|
310
|
+
const open = this.#open.get(prompt);
|
|
311
|
+
if (open?.frame.kind !== "ask") return "unknown_prompt";
|
|
312
|
+
if (!open.admits(from)) return "forbidden";
|
|
313
|
+
const given: OwnerAnswer = {
|
|
314
|
+
choices: answer.choices,
|
|
315
|
+
...(answer.text?.trim() ? { text: answer.text.trim() } : {}),
|
|
316
|
+
};
|
|
317
|
+
if (!checkAnswer(open.frame, given)) return "bad_frame";
|
|
318
|
+
open.end({ type: "answer", answer: given }, from);
|
|
319
|
+
return undefined;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Answers a questionnaire card. */
|
|
323
|
+
answers(
|
|
324
|
+
from: Answerer,
|
|
325
|
+
prompt: string,
|
|
326
|
+
answers: readonly (PromptAnswer | null)[],
|
|
327
|
+
note?: string,
|
|
328
|
+
): RefusalCode | undefined {
|
|
329
|
+
const open = this.#open.get(prompt);
|
|
330
|
+
if (open?.frame.kind !== "questionnaire") return "unknown_prompt";
|
|
331
|
+
if (!open.admits(from)) return "forbidden";
|
|
332
|
+
const answer: QuestionnaireAnswer = {
|
|
333
|
+
answers: answers.map((a) =>
|
|
334
|
+
a === null || !(a.choices.length > 0 || a.text?.trim())
|
|
335
|
+
? undefined
|
|
336
|
+
: {
|
|
337
|
+
choices: a.choices,
|
|
338
|
+
...(a.text?.trim() ? { text: a.text.trim() } : {}),
|
|
339
|
+
},
|
|
340
|
+
),
|
|
341
|
+
...(note?.trim() ? { note: note.trim() } : {}),
|
|
342
|
+
};
|
|
343
|
+
if (!checkQuestionnaireAnswer({ questions: open.frame.questions }, answer))
|
|
344
|
+
return "bad_frame";
|
|
345
|
+
open.end({ type: "answers", answer }, from);
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Cancels a question or questionnaire card; the turn is told `declined`. */
|
|
350
|
+
cancel(from: Answerer, prompt: string): RefusalCode | undefined {
|
|
351
|
+
const open = this.#open.get(prompt);
|
|
352
|
+
if (!open || open.frame.kind === "approval") return "unknown_prompt";
|
|
353
|
+
if (!open.admits(from)) return "forbidden";
|
|
354
|
+
open.end({ type: "cancel" }, from);
|
|
355
|
+
return undefined;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Who may approve a call held at `minTier`, as a test of the person who answers; undefined when
|
|
360
|
+
* no one may, so the call is unavailable at once. The primary audience is every owner.
|
|
361
|
+
*/
|
|
362
|
+
#approvalAudience(
|
|
363
|
+
scope: PromptScope | undefined,
|
|
364
|
+
minTier: Tier,
|
|
365
|
+
approvers: readonly string[] | undefined,
|
|
366
|
+
speakerOnly: boolean,
|
|
367
|
+
): ((from: Answerer) => boolean) | undefined {
|
|
368
|
+
const tierOk = (from: Answerer) => atLeast(from.tier, minTier);
|
|
369
|
+
if (speakerOnly) {
|
|
370
|
+
// The speaker's alone: below the tier, or a speaker who is no person, no one answers.
|
|
371
|
+
if (
|
|
372
|
+
!scope ||
|
|
373
|
+
scope.principalId === SYSTEM_PRINCIPAL ||
|
|
374
|
+
!atLeast(scope.tier, minTier)
|
|
375
|
+
)
|
|
376
|
+
return undefined;
|
|
377
|
+
const speaker = scope.principalId;
|
|
378
|
+
return (from) => from.principalId === speaker && tierOk(from);
|
|
379
|
+
}
|
|
380
|
+
if (scope?.escalate === "none") {
|
|
381
|
+
// A private conversation's person is the only one who answers, and a call naming others is theirs only if named or an owner.
|
|
382
|
+
if (!atLeast(scope.tier, minTier)) return undefined;
|
|
383
|
+
if (
|
|
384
|
+
approvers?.length &&
|
|
385
|
+
!approvers.includes(scope.principalId) &&
|
|
386
|
+
scope.tier !== "owner"
|
|
387
|
+
)
|
|
388
|
+
return undefined;
|
|
389
|
+
const person = scope.principalId;
|
|
390
|
+
return (from) => from.principalId === person && tierOk(from);
|
|
391
|
+
}
|
|
392
|
+
return tierOk;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Who may answer a question: every owner, or a private conversation's person alone. */
|
|
396
|
+
#questionAudience(scope: PromptScope | undefined) {
|
|
397
|
+
if (scope?.escalate === "none") {
|
|
398
|
+
const person = scope.principalId;
|
|
399
|
+
return (from: Answerer) => from.principalId === person;
|
|
400
|
+
}
|
|
401
|
+
return (_from: Answerer) => true;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Shows a card and resolves with its answer, `cancelled` when its turn stops, `expired` after
|
|
406
|
+
* the day, or `pending` once the grace period passes for a turn that can be resumed.
|
|
407
|
+
*/
|
|
408
|
+
#post<T>(card: Card<T>): Promise<T> {
|
|
409
|
+
const { broadcast, graceMs, logger } = this.#options;
|
|
410
|
+
const ttlMs = this.#options.ttlMs ?? CARD_TTL_MS;
|
|
411
|
+
const { signal, supersede, room } = card;
|
|
412
|
+
if (signal?.aborted || supersede?.aborted)
|
|
413
|
+
return Promise.resolve(card.cancelled);
|
|
414
|
+
const id = card.frame.id;
|
|
415
|
+
const frame = { ...card.frame, at: new Date().toISOString() };
|
|
416
|
+
return new Promise<T>((resolve) => {
|
|
417
|
+
let late = false;
|
|
418
|
+
let grace: ReturnType<typeof setTimeout> | undefined;
|
|
419
|
+
const open: Open = {
|
|
420
|
+
room,
|
|
421
|
+
frame,
|
|
422
|
+
admits: card.admits,
|
|
423
|
+
abandon: () => onAbort(),
|
|
424
|
+
end: (ended, by) => {
|
|
425
|
+
if (!this.#open.has(id)) return;
|
|
426
|
+
const answeredLate = late;
|
|
427
|
+
if (!answeredLate && ended.type === "approval" && ended.approved)
|
|
428
|
+
card.approved?.(by, id);
|
|
429
|
+
close(outcomeOf(ended), undefined, by, answerOf(ended));
|
|
430
|
+
if (!answeredLate) {
|
|
431
|
+
resolve(card.valueOf(ended));
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
try {
|
|
435
|
+
const text = card.lateText?.(ended, by, id);
|
|
436
|
+
if (text === undefined) return;
|
|
437
|
+
Promise.resolve(this.#options.resume(room, by, text)).catch(
|
|
438
|
+
(error: unknown) =>
|
|
439
|
+
logger.warn({ room, err: error }, "late card answer lost"),
|
|
440
|
+
);
|
|
441
|
+
} catch (error) {
|
|
442
|
+
logger.warn({ room, err: error }, "late card answer lost");
|
|
443
|
+
}
|
|
444
|
+
},
|
|
445
|
+
};
|
|
446
|
+
const close = (
|
|
447
|
+
outcome: PromptOutcome,
|
|
448
|
+
note?: string,
|
|
449
|
+
by?: Answerer,
|
|
450
|
+
answer?: CardAnswer,
|
|
451
|
+
) => {
|
|
452
|
+
if (!this.#open.delete(id)) return;
|
|
453
|
+
clearTimeout(grace);
|
|
454
|
+
clearTimeout(ttl);
|
|
455
|
+
signal?.removeEventListener("abort", onAbort);
|
|
456
|
+
supersede?.removeEventListener("abort", onWithdraw);
|
|
457
|
+
const closedAt = new Date().toISOString();
|
|
458
|
+
const ended = {
|
|
459
|
+
outcome,
|
|
460
|
+
...(note ? { note } : {}),
|
|
461
|
+
...(by ? { by: by.name } : {}),
|
|
462
|
+
...(answer ? { answer } : {}),
|
|
463
|
+
};
|
|
464
|
+
// Kept before the sockets are told, so a page that reloads on the news finds it.
|
|
465
|
+
this.#keep({ room, frame: open.frame, ...ended, closedAt });
|
|
466
|
+
broadcast({ type: "prompt_closed", prompt: id, ...ended, closedAt });
|
|
467
|
+
};
|
|
468
|
+
const onAbort = () => {
|
|
469
|
+
if (!this.#open.has(id)) return;
|
|
470
|
+
close("cancelled");
|
|
471
|
+
resolve(card.cancelled);
|
|
472
|
+
};
|
|
473
|
+
const onWithdraw = () => {
|
|
474
|
+
if (!this.#open.has(id) || !supersede) return;
|
|
475
|
+
const wasLate = late;
|
|
476
|
+
close("withdrawn", withdrawalNote(supersede, ""));
|
|
477
|
+
if (!wasLate) resolve(card.cancelled);
|
|
478
|
+
};
|
|
479
|
+
const ttl = setTimeout(() => {
|
|
480
|
+
if (!this.#open.has(id)) return;
|
|
481
|
+
const wasLate = late;
|
|
482
|
+
close("expired");
|
|
483
|
+
if (!wasLate) resolve(card.expired);
|
|
484
|
+
}, ttlMs);
|
|
485
|
+
if (card.pending !== undefined)
|
|
486
|
+
grace = setTimeout(() => {
|
|
487
|
+
if (!this.#open.has(id)) return;
|
|
488
|
+
late = true;
|
|
489
|
+
signal?.removeEventListener("abort", onAbort);
|
|
490
|
+
open.frame = { ...open.frame, late: true } as PromptFrame;
|
|
491
|
+
broadcast({ type: "prompt", room, prompt: open.frame });
|
|
492
|
+
resolve(card.pending as T);
|
|
493
|
+
}, graceMs);
|
|
494
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
495
|
+
supersede?.addEventListener("abort", onWithdraw, { once: true });
|
|
496
|
+
this.#open.set(id, open);
|
|
497
|
+
broadcast({ type: "prompt", room, prompt: open.frame });
|
|
498
|
+
try {
|
|
499
|
+
this.#options.opened?.(room, open.frame);
|
|
500
|
+
} catch (error) {
|
|
501
|
+
logger.warn({ room, err: error }, "a card's listener failed");
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
#keep(card: ClosedCard): void {
|
|
507
|
+
const { closed, logger } = this.#options;
|
|
508
|
+
if (!closed) return;
|
|
509
|
+
const failed = (error: unknown) =>
|
|
510
|
+
logger.warn({ room: card.room, err: error }, "a card was not kept");
|
|
511
|
+
try {
|
|
512
|
+
Promise.resolve(closed(card)).catch(failed);
|
|
513
|
+
} catch (error) {
|
|
514
|
+
failed(error);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/** Closes every open card as cancelled and clears their timers, when the app stops. */
|
|
519
|
+
stop(): void {
|
|
520
|
+
for (const open of [...this.#open.values()]) open.abandon();
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function approvedBy(by: Answerer): ApprovedBy {
|
|
525
|
+
return {
|
|
526
|
+
principalId: by.principalId,
|
|
527
|
+
speakerId: by.speakerId,
|
|
528
|
+
name: by.name,
|
|
529
|
+
...(by.tier === "owner" ? { owner: true as const } : {}),
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export type { LateAnswer };
|