@domino-sdk/relay 0.14.1 → 0.15.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/dist/authoring.d.ts +2 -2
- package/dist/browser.d.ts +39 -39
- package/dist/browser.js +4 -55
- package/dist/chunk-CQ5YSQQE.js +61 -0
- package/dist/index.d.ts +102 -102
- package/dist/portal-proxy.d.ts +26 -1
- package/dist/portal-proxy.js +43 -0
- package/dist/react/index.d.ts +3847 -0
- package/dist/react/index.js +833 -0
- package/dist/{settings-CU2y-wUM.d.ts → settings-e0nHA0JA.d.ts} +1 -1
- package/dist/vite.d.ts +32 -0
- package/dist/vite.js +42 -0
- package/package.json +22 -2
|
@@ -0,0 +1,833 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createBrowserRelayClient
|
|
3
|
+
} from "../chunk-CQ5YSQQE.js";
|
|
4
|
+
import {
|
|
5
|
+
RelayError
|
|
6
|
+
} from "../chunk-YZJOZL4V.js";
|
|
7
|
+
import "../chunk-CJARNIVP.js";
|
|
8
|
+
import "../chunk-I2CCMGN6.js";
|
|
9
|
+
import "../chunk-FUQYKTOJ.js";
|
|
10
|
+
|
|
11
|
+
// src/react/provider.tsx
|
|
12
|
+
import { useState as useState3 } from "react";
|
|
13
|
+
|
|
14
|
+
// src/react/context.ts
|
|
15
|
+
import { createContext, useContext } from "react";
|
|
16
|
+
var ClientContext = createContext(null);
|
|
17
|
+
function useRelay() {
|
|
18
|
+
const client = useContext(ClientContext);
|
|
19
|
+
if (!client) {
|
|
20
|
+
throw new Error("Domino hooks require <RelayProvider>");
|
|
21
|
+
}
|
|
22
|
+
return client;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/react/experience.tsx
|
|
26
|
+
import {
|
|
27
|
+
createContext as createContext2,
|
|
28
|
+
useContext as useContext2,
|
|
29
|
+
useEffect as useEffect2,
|
|
30
|
+
useMemo,
|
|
31
|
+
useRef,
|
|
32
|
+
useState as useState2
|
|
33
|
+
} from "react";
|
|
34
|
+
|
|
35
|
+
// src/react/role-delivery.ts
|
|
36
|
+
function sanitizeDeliveries(deliveries) {
|
|
37
|
+
return deliveries.map(({ quest, status }) => ({ quest, status }));
|
|
38
|
+
}
|
|
39
|
+
function completionMark(quests) {
|
|
40
|
+
return quests.filter((q) => q.completed || q.completionCount > 0).map((q) => `${q.quest}:${Math.max(q.completionCount, 1)}`).sort().join(",");
|
|
41
|
+
}
|
|
42
|
+
function hasPending(deliveries) {
|
|
43
|
+
return deliveries.some((d) => d.status === "pending");
|
|
44
|
+
}
|
|
45
|
+
function roleDeliveryLabel(deliveries, quest) {
|
|
46
|
+
const mine = deliveries.filter((d) => d.quest === quest);
|
|
47
|
+
if (!mine.length) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
if (hasPending(mine)) {
|
|
51
|
+
return "Role delivery pending";
|
|
52
|
+
}
|
|
53
|
+
return mine.some((d) => d.status === "failed") ? "Role delivery failed" : "Role granted";
|
|
54
|
+
}
|
|
55
|
+
var PENDING_POLL_MS = 5e3;
|
|
56
|
+
var RETRY_DELAYS_MS = [1e3, 4e3];
|
|
57
|
+
function watchRoleDeliveries(options) {
|
|
58
|
+
const schedule = options.schedule ?? ((run2, ms) => {
|
|
59
|
+
const timer = setTimeout(run2, ms);
|
|
60
|
+
return () => clearTimeout(timer);
|
|
61
|
+
});
|
|
62
|
+
let active = true;
|
|
63
|
+
let reading = false;
|
|
64
|
+
let failures = 0;
|
|
65
|
+
let resting = false;
|
|
66
|
+
let cancel = () => {
|
|
67
|
+
};
|
|
68
|
+
async function run() {
|
|
69
|
+
cancel();
|
|
70
|
+
if (!active || reading) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
reading = true;
|
|
74
|
+
resting = false;
|
|
75
|
+
try {
|
|
76
|
+
const next = sanitizeDeliveries(await options.read());
|
|
77
|
+
if (!active) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
failures = 0;
|
|
81
|
+
options.onChange(next);
|
|
82
|
+
if (hasPending(next)) {
|
|
83
|
+
cancel = schedule(run, PENDING_POLL_MS);
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
if (!active) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const delay = RETRY_DELAYS_MS[failures];
|
|
90
|
+
failures += 1;
|
|
91
|
+
if (delay === void 0) {
|
|
92
|
+
failures = 0;
|
|
93
|
+
resting = true;
|
|
94
|
+
} else {
|
|
95
|
+
cancel = schedule(run, delay);
|
|
96
|
+
}
|
|
97
|
+
} finally {
|
|
98
|
+
reading = false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
void run();
|
|
102
|
+
return {
|
|
103
|
+
wake() {
|
|
104
|
+
if (resting) {
|
|
105
|
+
void run();
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
dispose() {
|
|
109
|
+
active = false;
|
|
110
|
+
cancel();
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// src/react/status.ts
|
|
116
|
+
function isFinished(view) {
|
|
117
|
+
return view.availability.kind === "completed" || view.completion.kind !== "keyed" && view.completed;
|
|
118
|
+
}
|
|
119
|
+
function isAutomatic(view) {
|
|
120
|
+
return view.trigger.kind === "automatic";
|
|
121
|
+
}
|
|
122
|
+
function runsSilently(view) {
|
|
123
|
+
return view.trigger.kind === "automatic" && (view.completion.kind === "keyed" || Boolean(view.trigger.observation));
|
|
124
|
+
}
|
|
125
|
+
function completionCountLabel(view) {
|
|
126
|
+
if (view.completion.kind !== "keyed" || view.completionCount < 1) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
return view.completionCount === 1 ? "Completed once" : `Completed ${view.completionCount.toLocaleString()} times`;
|
|
130
|
+
}
|
|
131
|
+
function canRepeat(view) {
|
|
132
|
+
return view.completion.kind === "keyed" && view.attempt?.status === "accepted" && canSubmit(view);
|
|
133
|
+
}
|
|
134
|
+
function visibleQuests(quests) {
|
|
135
|
+
return quests.filter((q) => q.visibility !== "hidden");
|
|
136
|
+
}
|
|
137
|
+
function questStatus(view) {
|
|
138
|
+
if (isFinished(view)) {
|
|
139
|
+
return { tone: "done", label: "Completed" };
|
|
140
|
+
}
|
|
141
|
+
if (view.lifecycle === "paused") {
|
|
142
|
+
return {
|
|
143
|
+
tone: "off",
|
|
144
|
+
label: "Paused",
|
|
145
|
+
detail: "This activity is paused for now."
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (view.lifecycle === "archived") {
|
|
149
|
+
return {
|
|
150
|
+
tone: "off",
|
|
151
|
+
label: "Closed",
|
|
152
|
+
detail: "This activity is no longer running."
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const availability = view.availability;
|
|
156
|
+
if (view.streak && availability.kind !== "locked") {
|
|
157
|
+
const streak = view.streak;
|
|
158
|
+
if (streak.milestone.status === "budget-exhausted") {
|
|
159
|
+
return {
|
|
160
|
+
tone: "attention",
|
|
161
|
+
label: "Milestone reached",
|
|
162
|
+
detail: "Your qualifying days are saved. This milestone's reward budget was exhausted."
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
const deadline = new Intl.DateTimeFormat("en", {
|
|
166
|
+
timeZone: streak.timeZone,
|
|
167
|
+
month: "short",
|
|
168
|
+
day: "numeric",
|
|
169
|
+
hour: "numeric",
|
|
170
|
+
minute: "2-digit",
|
|
171
|
+
timeZoneName: "short"
|
|
172
|
+
}).format(streak.nextBoundary);
|
|
173
|
+
return {
|
|
174
|
+
tone: streak.todayQualified ? "waiting" : "open",
|
|
175
|
+
label: `${streak.current} / ${streak.goal} days`,
|
|
176
|
+
detail: streak.todayQualified ? `Today counts. Your next community day starts ${deadline}. Best streak: ${streak.best} days.` : `Complete today's activity before ${deadline} ${streak.current ? "to keep your streak" : "to start your streak"}. Best streak: ${streak.best} days.`
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
if (runsSilently(view) && availability.kind !== "locked") {
|
|
180
|
+
return {
|
|
181
|
+
tone: "waiting",
|
|
182
|
+
label: "Automatic",
|
|
183
|
+
detail: view.completion.kind === "keyed" ? [
|
|
184
|
+
completionCountLabel(view),
|
|
185
|
+
"This counts by itself each time you qualify."
|
|
186
|
+
].filter(Boolean).join(". ") : "This updates by itself when you qualify."
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (isAutomatic(view) && availability.kind !== "locked") {
|
|
190
|
+
return {
|
|
191
|
+
tone: "waiting",
|
|
192
|
+
label: "In progress",
|
|
193
|
+
detail: "This updates by itself when you qualify."
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
switch (availability.kind) {
|
|
197
|
+
case "completed":
|
|
198
|
+
return { tone: "done", label: "Completed" };
|
|
199
|
+
case "locked":
|
|
200
|
+
return {
|
|
201
|
+
tone: "locked",
|
|
202
|
+
label: "Locked",
|
|
203
|
+
detail: `Complete ${list(availability.missing.map((m) => m.title))} first.`
|
|
204
|
+
};
|
|
205
|
+
case "checking":
|
|
206
|
+
return {
|
|
207
|
+
tone: "waiting",
|
|
208
|
+
label: "Checking",
|
|
209
|
+
detail: "Your submission is being verified."
|
|
210
|
+
};
|
|
211
|
+
case "needs-review":
|
|
212
|
+
return {
|
|
213
|
+
tone: "waiting",
|
|
214
|
+
label: "In review",
|
|
215
|
+
detail: "A person is reviewing your submission."
|
|
216
|
+
};
|
|
217
|
+
case "retryable":
|
|
218
|
+
return {
|
|
219
|
+
tone: "attention",
|
|
220
|
+
label: "Didn't go through",
|
|
221
|
+
detail: "Verification couldn't run. Try again. Nothing was lost."
|
|
222
|
+
};
|
|
223
|
+
case "staff-required":
|
|
224
|
+
return {
|
|
225
|
+
tone: "waiting",
|
|
226
|
+
label: "Waiting for staff",
|
|
227
|
+
detail: "A staff member confirms this activity for you."
|
|
228
|
+
};
|
|
229
|
+
case "automatic":
|
|
230
|
+
return {
|
|
231
|
+
tone: "waiting",
|
|
232
|
+
label: "In progress",
|
|
233
|
+
detail: "This updates by itself when you qualify."
|
|
234
|
+
};
|
|
235
|
+
case "available":
|
|
236
|
+
return view.attempt?.status === "rejected" ? {
|
|
237
|
+
tone: "attention",
|
|
238
|
+
label: "Try again",
|
|
239
|
+
detail: view.attempt.reason ?? "That submission wasn't accepted."
|
|
240
|
+
} : { tone: "open", label: "Ready" };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
function canSubmit(view) {
|
|
244
|
+
return !isFinished(view) && !isAutomatic(view) && view.lifecycle === "active" && view.availability.kind === "available";
|
|
245
|
+
}
|
|
246
|
+
function progressOf(quests) {
|
|
247
|
+
const counted = quests.filter(
|
|
248
|
+
(q) => q.lifecycle !== "archived" && q.completion.kind !== "keyed"
|
|
249
|
+
);
|
|
250
|
+
return {
|
|
251
|
+
total: counted.length,
|
|
252
|
+
done: counted.filter(isFinished).length
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
function list(items) {
|
|
256
|
+
return new Intl.ListFormat("en", { type: "conjunction" }).format(
|
|
257
|
+
items.map((item) => `\u201C${item}\u201D`)
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/react/use-role-deliveries.ts
|
|
262
|
+
import { useEffect, useState } from "react";
|
|
263
|
+
function useRoleDeliveries(relay, member, completions) {
|
|
264
|
+
const [state, setState] = useState(null);
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
if (!member || !completions) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
const watch = watchRoleDeliveries({
|
|
270
|
+
read: async () => (await relay.me.rewardDeliveries()).deliveries,
|
|
271
|
+
onChange: (deliveries) => setState({ member, deliveries })
|
|
272
|
+
});
|
|
273
|
+
const wake = () => {
|
|
274
|
+
if (document.visibilityState === "visible") {
|
|
275
|
+
watch.wake();
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
window.addEventListener("focus", wake);
|
|
279
|
+
document.addEventListener("visibilitychange", wake);
|
|
280
|
+
return () => {
|
|
281
|
+
watch.dispose();
|
|
282
|
+
window.removeEventListener("focus", wake);
|
|
283
|
+
document.removeEventListener("visibilitychange", wake);
|
|
284
|
+
};
|
|
285
|
+
}, [relay, member, completions]);
|
|
286
|
+
return member && completions && state?.member === member ? state.deliveries : [];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// src/react/experience.tsx
|
|
290
|
+
import { jsx } from "react/jsx-runtime";
|
|
291
|
+
var Context = createContext2(null);
|
|
292
|
+
function ExperienceProvider({ children }) {
|
|
293
|
+
const relay = useRelay();
|
|
294
|
+
const [experience, setExperience] = useState2({
|
|
295
|
+
status: "loading"
|
|
296
|
+
});
|
|
297
|
+
const [owner, setOwner] = useState2(null);
|
|
298
|
+
const [member, setMember] = useState2(null);
|
|
299
|
+
const observer = useRef(null);
|
|
300
|
+
useEffect2(() => {
|
|
301
|
+
const subscription = relay.quests.observe((observation) => {
|
|
302
|
+
if (observation.kind === "session" || observation.kind === "ready") {
|
|
303
|
+
const principal = observation.session?.principal;
|
|
304
|
+
setMember(principal?.kind === "member" ? principal.member : null);
|
|
305
|
+
} else if (observation.kind === "loading") {
|
|
306
|
+
setMember(null);
|
|
307
|
+
}
|
|
308
|
+
if (observation.kind === "ready") {
|
|
309
|
+
setOwner(observation.progress.member);
|
|
310
|
+
}
|
|
311
|
+
setExperience((previous) => {
|
|
312
|
+
switch (observation.kind) {
|
|
313
|
+
case "loading":
|
|
314
|
+
return { status: "loading" };
|
|
315
|
+
case "session":
|
|
316
|
+
return observation.session?.principal.kind === "member" ? previous : { status: "signed-out" };
|
|
317
|
+
case "ready":
|
|
318
|
+
return {
|
|
319
|
+
status: "ready",
|
|
320
|
+
// Hidden quests never reach the UI, on any poll.
|
|
321
|
+
quests: visibleQuests(observation.progress.quests),
|
|
322
|
+
collections: observation.progress.collections.map(
|
|
323
|
+
(collection) => ({
|
|
324
|
+
...collection,
|
|
325
|
+
quests: visibleQuests(collection.quests)
|
|
326
|
+
})
|
|
327
|
+
),
|
|
328
|
+
stale: null
|
|
329
|
+
};
|
|
330
|
+
case "error":
|
|
331
|
+
return previous.status === "ready" ? { ...previous, stale: observation.error.message } : { status: "error", message: observation.error.message };
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
observer.current = subscription;
|
|
336
|
+
return () => subscription.dispose();
|
|
337
|
+
}, [relay]);
|
|
338
|
+
const mine = experience.status === "ready" && member !== null && member === owner;
|
|
339
|
+
const deliveries = useRoleDeliveries(
|
|
340
|
+
relay,
|
|
341
|
+
mine ? member : null,
|
|
342
|
+
mine ? completionMark([
|
|
343
|
+
...experience.quests,
|
|
344
|
+
...experience.collections.flatMap((c) => c.quests)
|
|
345
|
+
]) : ""
|
|
346
|
+
);
|
|
347
|
+
const value = useMemo(
|
|
348
|
+
() => ({
|
|
349
|
+
experience,
|
|
350
|
+
member: mine ? member : null,
|
|
351
|
+
deliveries,
|
|
352
|
+
refresh: async () => observer.current?.refresh()
|
|
353
|
+
}),
|
|
354
|
+
[experience, mine, member, deliveries]
|
|
355
|
+
);
|
|
356
|
+
return /* @__PURE__ */ jsx(Context, { value, children });
|
|
357
|
+
}
|
|
358
|
+
function useExperience() {
|
|
359
|
+
const value = useContext2(Context);
|
|
360
|
+
if (!value) {
|
|
361
|
+
throw new Error("useExperience requires <RelayProvider>");
|
|
362
|
+
}
|
|
363
|
+
return value;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// src/react/provider.tsx
|
|
367
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
368
|
+
function RelayProvider({
|
|
369
|
+
client,
|
|
370
|
+
baseUrl = "/relay",
|
|
371
|
+
children
|
|
372
|
+
}) {
|
|
373
|
+
const [value] = useState3(
|
|
374
|
+
() => client ?? createBrowserRelayClient({ baseUrl })
|
|
375
|
+
);
|
|
376
|
+
return /* @__PURE__ */ jsx2(ClientContext, { value, children: /* @__PURE__ */ jsx2(ExperienceProvider, { children }) });
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// src/react/quest-groups.ts
|
|
380
|
+
function questGroups(experience, order = []) {
|
|
381
|
+
const placed = new Set(
|
|
382
|
+
experience.collections.flatMap((c) => c.quests.map((q) => q.quest))
|
|
383
|
+
);
|
|
384
|
+
const rank = (view) => {
|
|
385
|
+
const index = order.indexOf(view.key ?? view.quest);
|
|
386
|
+
const fallback = index === -1 ? order.indexOf(view.quest) : index;
|
|
387
|
+
return fallback === -1 ? order.length : fallback;
|
|
388
|
+
};
|
|
389
|
+
const unplaced = experience.quests.filter((q) => !placed.has(q.quest)).sort((a, b) => rank(a) - rank(b));
|
|
390
|
+
return [
|
|
391
|
+
...experience.collections,
|
|
392
|
+
...unplaced.length ? [{ id: "unplaced", title: "", quests: unplaced }] : []
|
|
393
|
+
].map((group) => ({
|
|
394
|
+
...group,
|
|
395
|
+
quests: group.quests.filter((q) => q.lifecycle !== "archived")
|
|
396
|
+
})).filter((group) => group.quests.length > 0);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/react/use-quest.ts
|
|
400
|
+
import { useCallback, useEffect as useEffect3, useState as useState4 } from "react";
|
|
401
|
+
function useQuest(observed) {
|
|
402
|
+
const relay = useRelay();
|
|
403
|
+
const { refresh } = useExperience();
|
|
404
|
+
const [latest, setLatest] = useState4(null);
|
|
405
|
+
const [draft, setDraft] = useState4(null);
|
|
406
|
+
const [notice, setNotice] = useState4(null);
|
|
407
|
+
const [error, setError] = useState4(null);
|
|
408
|
+
const [busy, setBusy] = useState4(false);
|
|
409
|
+
const [discardedAttempt, setDiscardedAttempt] = useState4(null);
|
|
410
|
+
const [saved, setSaved] = useState4("none");
|
|
411
|
+
const [receipt, setReceipt] = useState4(null);
|
|
412
|
+
useEffect3(() => setLatest(null), [observed.release]);
|
|
413
|
+
const view = latest ?? observed;
|
|
414
|
+
const { quest, release } = view;
|
|
415
|
+
const completed = isFinished(view) || isAutomatic(view);
|
|
416
|
+
const attempt = view.attempt?.id;
|
|
417
|
+
useEffect3(() => {
|
|
418
|
+
let active = true;
|
|
419
|
+
relay.quests.open(view).then((controller) => controller.status()).then((status) => {
|
|
420
|
+
if (!active) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
setSaved(
|
|
424
|
+
completed || status.kind === "empty" || status.kind === "submitted" ? "none" : status.kind === "needs-review" ? "outdated" : "interrupted"
|
|
425
|
+
);
|
|
426
|
+
}).catch(() => {
|
|
427
|
+
});
|
|
428
|
+
return () => {
|
|
429
|
+
active = false;
|
|
430
|
+
};
|
|
431
|
+
}, [relay, quest, release, completed, attempt, busy]);
|
|
432
|
+
const run = useCallback(
|
|
433
|
+
async (action) => {
|
|
434
|
+
setBusy(true);
|
|
435
|
+
setError(null);
|
|
436
|
+
setNotice(null);
|
|
437
|
+
try {
|
|
438
|
+
const result = await action();
|
|
439
|
+
if (result.kind === "quest-updated") {
|
|
440
|
+
setLatest(result.latest);
|
|
441
|
+
setDraft(result.draft);
|
|
442
|
+
setNotice("quest-updated");
|
|
443
|
+
} else {
|
|
444
|
+
setDraft(null);
|
|
445
|
+
setReceipt(result.attempt.id);
|
|
446
|
+
setNotice("received");
|
|
447
|
+
}
|
|
448
|
+
await refresh();
|
|
449
|
+
} catch (cause) {
|
|
450
|
+
setError(
|
|
451
|
+
cause instanceof Error ? cause.message : "Something went wrong."
|
|
452
|
+
);
|
|
453
|
+
} finally {
|
|
454
|
+
setBusy(false);
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
[refresh]
|
|
458
|
+
);
|
|
459
|
+
return {
|
|
460
|
+
/** The newest known version of the quest. Render this, not the prop. */
|
|
461
|
+
view,
|
|
462
|
+
/** Input recovered after the quest changed. Prefill the form with it. */
|
|
463
|
+
draft,
|
|
464
|
+
notice,
|
|
465
|
+
error,
|
|
466
|
+
busy,
|
|
467
|
+
saved,
|
|
468
|
+
/** The attempt the last "received" notice is about. */
|
|
469
|
+
receipt,
|
|
470
|
+
startingFresh: view.attempt?.id === discardedAttempt && view.attempt?.status === "failed",
|
|
471
|
+
restart: async () => {
|
|
472
|
+
setBusy(true);
|
|
473
|
+
setError(null);
|
|
474
|
+
try {
|
|
475
|
+
await (await relay.quests.open(view)).discard();
|
|
476
|
+
setDiscardedAttempt(view.attempt?.id ?? null);
|
|
477
|
+
setSaved("none");
|
|
478
|
+
setDraft(null);
|
|
479
|
+
setNotice(null);
|
|
480
|
+
} catch (cause) {
|
|
481
|
+
setError(
|
|
482
|
+
cause instanceof Error ? cause.message : "Could not start a new submission."
|
|
483
|
+
);
|
|
484
|
+
} finally {
|
|
485
|
+
setBusy(false);
|
|
486
|
+
}
|
|
487
|
+
},
|
|
488
|
+
submit: (input) => run(async () => {
|
|
489
|
+
if (view.connections.length) {
|
|
490
|
+
const accounts = await relay.auth.accounts();
|
|
491
|
+
const missing = view.connections.find(
|
|
492
|
+
(provider) => !accounts.some(
|
|
493
|
+
(account) => account.provider === provider && account.configured && account.status === "connected"
|
|
494
|
+
)
|
|
495
|
+
);
|
|
496
|
+
if (missing) {
|
|
497
|
+
throw new Error(`Connect ${missing} before verifying.`);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
const controller = await relay.quests.open(view);
|
|
501
|
+
if (controller.kind === "claim" && input.kind === "claim") {
|
|
502
|
+
return controller.submit();
|
|
503
|
+
}
|
|
504
|
+
if (controller.kind === "quiz" && input.kind === "quiz") {
|
|
505
|
+
return controller.submit(input.answers);
|
|
506
|
+
}
|
|
507
|
+
if (controller.kind === "photo" && input.kind === "photo") {
|
|
508
|
+
return controller.submit(input.file);
|
|
509
|
+
}
|
|
510
|
+
throw new Error("This activity changed. Reload and try again.");
|
|
511
|
+
}),
|
|
512
|
+
/** Finish an interrupted submission, or reopen an outdated draft. */
|
|
513
|
+
resume: () => run(async () => (await relay.quests.open(view)).resume()),
|
|
514
|
+
/**
|
|
515
|
+
* Re-run verification that failed to run. Never creates a new attempt.
|
|
516
|
+
* Domino refuses this for automatic quests, so never offer it for them.
|
|
517
|
+
*/
|
|
518
|
+
retry: () => run(async () => {
|
|
519
|
+
if (isAutomatic(view)) {
|
|
520
|
+
throw new Error("This activity updates by itself.");
|
|
521
|
+
}
|
|
522
|
+
return {
|
|
523
|
+
kind: "submitted",
|
|
524
|
+
attempt: await relay.quests.retry(view)
|
|
525
|
+
};
|
|
526
|
+
})
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// src/react/auth.ts
|
|
531
|
+
import { useEffect as useEffect4, useState as useState5 } from "react";
|
|
532
|
+
function useSignInMethods() {
|
|
533
|
+
const relay = useRelay();
|
|
534
|
+
const [methods, setMethods] = useState5(null);
|
|
535
|
+
useEffect4(() => {
|
|
536
|
+
let active = true;
|
|
537
|
+
relay.auth.providers().then((result) => {
|
|
538
|
+
if (!active) {
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
setMethods({
|
|
542
|
+
providers: result.providers.filter((p) => p.configured).map((p) => p.provider),
|
|
543
|
+
email: result.email
|
|
544
|
+
});
|
|
545
|
+
}).catch(() => active && setMethods({ providers: [], email: true }));
|
|
546
|
+
return () => {
|
|
547
|
+
active = false;
|
|
548
|
+
};
|
|
549
|
+
}, [relay]);
|
|
550
|
+
return methods;
|
|
551
|
+
}
|
|
552
|
+
function useSignIn() {
|
|
553
|
+
const relay = useRelay();
|
|
554
|
+
const [challenge, setChallenge] = useState5(null);
|
|
555
|
+
const [busy, setBusy] = useState5(false);
|
|
556
|
+
const [error, setError] = useState5(null);
|
|
557
|
+
async function run(task, fallback) {
|
|
558
|
+
setBusy(true);
|
|
559
|
+
setError(null);
|
|
560
|
+
try {
|
|
561
|
+
await task();
|
|
562
|
+
} catch (cause) {
|
|
563
|
+
setError(
|
|
564
|
+
cause instanceof Error && cause.message ? cause.message : fallback
|
|
565
|
+
);
|
|
566
|
+
} finally {
|
|
567
|
+
setBusy(false);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return {
|
|
571
|
+
busy,
|
|
572
|
+
error,
|
|
573
|
+
/** True once a code was sent and the form should ask for it. */
|
|
574
|
+
codeSent: challenge !== null,
|
|
575
|
+
sendCode: (email) => run(async () => {
|
|
576
|
+
setChallenge((await relay.auth.emailStart(email)).challenge);
|
|
577
|
+
}, "Couldn't send a code. Check the address and try again."),
|
|
578
|
+
verifyCode: (code) => run(async () => {
|
|
579
|
+
if (!challenge) {
|
|
580
|
+
throw new Error("Request a new code first.");
|
|
581
|
+
}
|
|
582
|
+
await relay.auth.emailVerify(challenge, code.trim());
|
|
583
|
+
setChallenge(null);
|
|
584
|
+
}, "That code didn't work. Check it, or request a new one."),
|
|
585
|
+
startOver: () => {
|
|
586
|
+
setChallenge(null);
|
|
587
|
+
setError(null);
|
|
588
|
+
},
|
|
589
|
+
withProvider: (provider) => run(async () => {
|
|
590
|
+
const { url } = await relay.auth.start(provider, "sign-in");
|
|
591
|
+
window.location.assign(url);
|
|
592
|
+
}, "Couldn't start sign-in. Try again.")
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
function useSignOut() {
|
|
596
|
+
const relay = useRelay();
|
|
597
|
+
const [busy, setBusy] = useState5(false);
|
|
598
|
+
return {
|
|
599
|
+
busy,
|
|
600
|
+
signOut: async () => {
|
|
601
|
+
setBusy(true);
|
|
602
|
+
try {
|
|
603
|
+
await relay.auth.signOut();
|
|
604
|
+
} finally {
|
|
605
|
+
setBusy(false);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// src/react/accounts.ts
|
|
612
|
+
import { useCallback as useCallback2, useEffect as useEffect5, useState as useState6 } from "react";
|
|
613
|
+
var CHANGED = "relay-accounts-changed";
|
|
614
|
+
function isConnected(accounts, provider) {
|
|
615
|
+
return Boolean(
|
|
616
|
+
accounts?.some(
|
|
617
|
+
(account) => account.provider === provider && account.configured && account.status === "connected"
|
|
618
|
+
)
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
function useAccounts() {
|
|
622
|
+
const relay = useRelay();
|
|
623
|
+
const { member } = useExperience();
|
|
624
|
+
const [accounts, setAccounts] = useState6(null);
|
|
625
|
+
const [error, setError] = useState6(null);
|
|
626
|
+
const [busy, setBusy] = useState6(false);
|
|
627
|
+
const refresh = useCallback2(async () => {
|
|
628
|
+
try {
|
|
629
|
+
setAccounts(await relay.auth.accounts());
|
|
630
|
+
setError(null);
|
|
631
|
+
} catch (cause) {
|
|
632
|
+
setError(
|
|
633
|
+
cause instanceof Error && cause.message ? cause.message : "Could not load connected accounts."
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
}, [relay]);
|
|
637
|
+
useEffect5(() => {
|
|
638
|
+
if (!member) {
|
|
639
|
+
setAccounts(null);
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
const update = () => void refresh();
|
|
643
|
+
update();
|
|
644
|
+
window.addEventListener("focus", update);
|
|
645
|
+
window.addEventListener(CHANGED, update);
|
|
646
|
+
return () => {
|
|
647
|
+
window.removeEventListener("focus", update);
|
|
648
|
+
window.removeEventListener(CHANGED, update);
|
|
649
|
+
};
|
|
650
|
+
}, [member, refresh]);
|
|
651
|
+
async function run(task, fallback) {
|
|
652
|
+
setBusy(true);
|
|
653
|
+
setError(null);
|
|
654
|
+
try {
|
|
655
|
+
await task();
|
|
656
|
+
} catch (cause) {
|
|
657
|
+
setError(
|
|
658
|
+
cause instanceof Error && cause.message ? cause.message : fallback
|
|
659
|
+
);
|
|
660
|
+
} finally {
|
|
661
|
+
setBusy(false);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
/** Null until read, and while nobody is signed in. */
|
|
666
|
+
accounts,
|
|
667
|
+
error,
|
|
668
|
+
busy,
|
|
669
|
+
refresh,
|
|
670
|
+
isConnected: (provider) => isConnected(accounts, provider),
|
|
671
|
+
connect: (provider) => run(async () => {
|
|
672
|
+
const { url } = await relay.auth.start(provider, "connect");
|
|
673
|
+
window.location.assign(url);
|
|
674
|
+
}, "Couldn't start connecting. Try again."),
|
|
675
|
+
disconnect: (provider) => run(async () => {
|
|
676
|
+
await relay.auth.disconnect(provider);
|
|
677
|
+
window.dispatchEvent(new Event(CHANGED));
|
|
678
|
+
}, "Couldn't disconnect. Try again.")
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// src/react/referrals.ts
|
|
683
|
+
import { useCallback as useCallback3, useEffect as useEffect6, useRef as useRef2, useState as useState7 } from "react";
|
|
684
|
+
var PENDING = "domino:pending-referral";
|
|
685
|
+
var CODE = /^[a-zA-Z0-9_-]{1,100}$/;
|
|
686
|
+
function captureReferralCode(param = "ref") {
|
|
687
|
+
if (typeof window === "undefined") {
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
const code = new URL(window.location.href).searchParams.get(param);
|
|
691
|
+
try {
|
|
692
|
+
if (code && CODE.test(code)) {
|
|
693
|
+
window.sessionStorage.setItem(PENDING, code);
|
|
694
|
+
}
|
|
695
|
+
return window.sessionStorage.getItem(PENDING);
|
|
696
|
+
} catch {
|
|
697
|
+
return code && CODE.test(code) ? code : null;
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
function forgetReferralCode() {
|
|
701
|
+
try {
|
|
702
|
+
window.sessionStorage.removeItem(PENDING);
|
|
703
|
+
} catch {
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
function useInvitation(param = "ref") {
|
|
707
|
+
const relay = useRelay();
|
|
708
|
+
const { member } = useExperience();
|
|
709
|
+
const [state, setState] = useState7(() => {
|
|
710
|
+
const code = captureReferralCode(param);
|
|
711
|
+
return code ? { status: "pending", code } : { status: "none" };
|
|
712
|
+
});
|
|
713
|
+
const started = useRef2(null);
|
|
714
|
+
const accept = useCallback3(
|
|
715
|
+
async (code) => {
|
|
716
|
+
setState({ status: "accepting", code });
|
|
717
|
+
try {
|
|
718
|
+
await relay.referrals.accept({ code, actionId: `accept-${code}` });
|
|
719
|
+
forgetReferralCode();
|
|
720
|
+
setState({ status: "accepted", code });
|
|
721
|
+
} catch (cause) {
|
|
722
|
+
const refused = cause instanceof RelayError && cause.status >= 400 && cause.status < 500;
|
|
723
|
+
if (refused) {
|
|
724
|
+
forgetReferralCode();
|
|
725
|
+
}
|
|
726
|
+
setState({
|
|
727
|
+
status: refused ? "rejected" : "failed",
|
|
728
|
+
code,
|
|
729
|
+
reason: cause instanceof Error && cause.message ? cause.message : "That invitation code couldn't be used."
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
},
|
|
733
|
+
[relay]
|
|
734
|
+
);
|
|
735
|
+
useEffect6(() => {
|
|
736
|
+
if (member && state.status === "pending" && started.current !== `${member}:${state.code}`) {
|
|
737
|
+
started.current = `${member}:${state.code}`;
|
|
738
|
+
void accept(state.code);
|
|
739
|
+
}
|
|
740
|
+
}, [member, state, accept]);
|
|
741
|
+
return { invitation: state, accept };
|
|
742
|
+
}
|
|
743
|
+
function useReferralSummary(options = {}) {
|
|
744
|
+
const relay = useRelay();
|
|
745
|
+
const { member } = useExperience();
|
|
746
|
+
const [summary, setSummary] = useState7(null);
|
|
747
|
+
const [error, setError] = useState7(null);
|
|
748
|
+
const refresh = useCallback3(async () => {
|
|
749
|
+
try {
|
|
750
|
+
setSummary(await relay.referrals.summary());
|
|
751
|
+
setError(null);
|
|
752
|
+
} catch (cause) {
|
|
753
|
+
setError(
|
|
754
|
+
cause instanceof Error && cause.message ? cause.message : "Could not load your invitations."
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
}, [relay]);
|
|
758
|
+
useEffect6(() => {
|
|
759
|
+
setSummary(null);
|
|
760
|
+
if (member) {
|
|
761
|
+
void refresh();
|
|
762
|
+
}
|
|
763
|
+
}, [member, refresh]);
|
|
764
|
+
let link = null;
|
|
765
|
+
if (summary && typeof window !== "undefined") {
|
|
766
|
+
const url = new URL(options.path ?? "/", window.location.origin);
|
|
767
|
+
url.searchParams.set(options.param ?? "ref", summary.code);
|
|
768
|
+
link = url.toString();
|
|
769
|
+
}
|
|
770
|
+
return { summary, link, error, refresh };
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// src/react/leaderboards.ts
|
|
774
|
+
import { useCallback as useCallback4, useEffect as useEffect7, useState as useState8 } from "react";
|
|
775
|
+
function useLeaderboard(board, options = {}) {
|
|
776
|
+
const relay = useRelay();
|
|
777
|
+
const { member } = useExperience();
|
|
778
|
+
const { limit = 25, intervalMs = 3e4 } = options;
|
|
779
|
+
const [state, setState] = useState8({ status: "loading" });
|
|
780
|
+
const refresh = useCallback4(async () => {
|
|
781
|
+
try {
|
|
782
|
+
const page = await relay.leaderboards.standings(board, { limit });
|
|
783
|
+
setState({ status: "ready", page, stale: null });
|
|
784
|
+
} catch (cause) {
|
|
785
|
+
const message = cause instanceof Error && cause.message ? cause.message : "Could not load the leaderboard.";
|
|
786
|
+
setState(
|
|
787
|
+
(previous) => previous.status === "ready" ? { ...previous, stale: message } : { status: "error", message }
|
|
788
|
+
);
|
|
789
|
+
}
|
|
790
|
+
}, [relay, board, limit]);
|
|
791
|
+
useEffect7(() => {
|
|
792
|
+
void refresh();
|
|
793
|
+
const timer = setInterval(() => {
|
|
794
|
+
if (document.visibilityState === "visible") {
|
|
795
|
+
void refresh();
|
|
796
|
+
}
|
|
797
|
+
}, intervalMs);
|
|
798
|
+
return () => clearInterval(timer);
|
|
799
|
+
}, [refresh, member, intervalMs]);
|
|
800
|
+
return { ...state, refresh };
|
|
801
|
+
}
|
|
802
|
+
export {
|
|
803
|
+
PENDING_POLL_MS,
|
|
804
|
+
RETRY_DELAYS_MS,
|
|
805
|
+
RelayProvider,
|
|
806
|
+
canRepeat,
|
|
807
|
+
canSubmit,
|
|
808
|
+
captureReferralCode,
|
|
809
|
+
completionCountLabel,
|
|
810
|
+
completionMark,
|
|
811
|
+
hasPending,
|
|
812
|
+
isAutomatic,
|
|
813
|
+
isConnected,
|
|
814
|
+
isFinished,
|
|
815
|
+
progressOf,
|
|
816
|
+
questGroups,
|
|
817
|
+
questStatus,
|
|
818
|
+
roleDeliveryLabel,
|
|
819
|
+
runsSilently,
|
|
820
|
+
sanitizeDeliveries,
|
|
821
|
+
useAccounts,
|
|
822
|
+
useExperience,
|
|
823
|
+
useInvitation,
|
|
824
|
+
useLeaderboard,
|
|
825
|
+
useQuest,
|
|
826
|
+
useReferralSummary,
|
|
827
|
+
useRelay,
|
|
828
|
+
useSignIn,
|
|
829
|
+
useSignInMethods,
|
|
830
|
+
useSignOut,
|
|
831
|
+
visibleQuests,
|
|
832
|
+
watchRoleDeliveries
|
|
833
|
+
};
|