@domino-sdk/relay 1.0.0-next.8 → 1.0.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.
Files changed (59) hide show
  1. package/README.md +1 -1
  2. package/dist/activity-DeKJsRry.d.ts +9112 -0
  3. package/dist/authoring.d.ts +1135 -285
  4. package/dist/authoring.js +545 -269
  5. package/dist/automatic-outcomes-B1xrETxm.d.ts +758 -0
  6. package/dist/avatar.d.ts +2 -2
  7. package/dist/browser-DFe7sWSN.d.ts +4154 -0
  8. package/dist/browser.d.ts +11 -2528
  9. package/dist/browser.js +8 -6
  10. package/dist/capability-error-DNMwKmlL.d.ts +458 -0
  11. package/dist/{chunk-V7EZUZ3Z.js → chunk-36HTX2BM.js} +389 -169
  12. package/dist/chunk-6QJ3LADK.js +112 -0
  13. package/dist/{chunk-77H7APVD.js → chunk-BGGKNIRP.js} +1 -1
  14. package/dist/chunk-EYH5RDIE.js +496 -0
  15. package/dist/chunk-GQKYAQM6.js +84 -0
  16. package/dist/chunk-GUP7YRR4.js +42 -0
  17. package/dist/{chunk-V5R2DMGY.js → chunk-GZ6FJAXN.js} +380 -128
  18. package/dist/chunk-JON5SESL.js +2986 -0
  19. package/dist/chunk-MC6V6VMG.js +49 -0
  20. package/dist/chunk-NPGENI7K.js +37 -0
  21. package/dist/chunk-VFHHLN6A.js +284 -0
  22. package/dist/chunk-WJSSJ7AF.js +151 -0
  23. package/dist/{chunk-QBOHLT7M.js → chunk-XFMSUPNS.js} +1 -1
  24. package/dist/{chunk-6A6K5RUD.js → chunk-ZVIRG24J.js} +145 -42
  25. package/dist/{client-CK4nwt7V.d.ts → client-XiuFnug_.d.ts} +2193 -562
  26. package/dist/{submission-input-C9qm9ixd.d.ts → groups-Ck-OxzjD.d.ts} +919 -1142
  27. package/dist/identity-integration-GxAHdKR2.d.ts +58 -0
  28. package/dist/{images--vZ9U0kr.d.ts → images-Dv-P5VBw.d.ts} +2 -2
  29. package/dist/index.d.ts +13 -9
  30. package/dist/index.js +17 -6
  31. package/dist/internal.d.ts +82 -43
  32. package/dist/internal.js +58 -90
  33. package/dist/offers-Cy-e4o5x.d.ts +431 -0
  34. package/dist/{pages-BzqJuI69.d.ts → outcomes-kVB-Juzs.d.ts} +2386 -1490
  35. package/dist/pages-BBYD21IE.d.ts +359 -0
  36. package/dist/participant-rewards-3duLb8YJ.d.ts +668 -0
  37. package/dist/portal-proxy.js +22 -4
  38. package/dist/{profiles-DDMFBRKe.d.ts → profiles-D2aFcOxu.d.ts} +15 -15
  39. package/dist/react/index.d.ts +3122 -1011
  40. package/dist/react/index.js +1627 -779
  41. package/dist/request-error-DWduJCGV.d.ts +62 -0
  42. package/dist/schema-DfJzH2ci.d.ts +5644 -0
  43. package/dist/schema.d.ts +15 -4216
  44. package/dist/schema.js +322 -81
  45. package/dist/server/index.d.ts +81498 -0
  46. package/dist/server/index.js +2520 -0
  47. package/dist/{streaks-CwGReWGt.d.ts → streaks-CWyq8Px5.d.ts} +2 -4
  48. package/dist/submission-input-BZkm6ohU.d.ts +290 -0
  49. package/dist/vite.d.ts +1 -1
  50. package/dist/vite.js +1 -0
  51. package/dist/{wallet-BGOfFkG9.d.ts → wallet-YssWxGg8.d.ts} +35 -38
  52. package/package.json +8 -2
  53. package/dist/chunk-MRIURJE3.js +0 -257
  54. package/dist/chunk-VARGF4JS.js +0 -1733
  55. package/dist/chunk-ZWY3R6B3.js +0 -26
  56. package/dist/collection-BGneJ6Bk.d.ts +0 -114
  57. package/dist/integrations-C3Sp558f.d.ts +0 -1297
  58. package/dist/point-ledgers-iS00Z_v0.d.ts +0 -37
  59. package/dist/schema-pnuWqvGn.d.ts +0 -3396
@@ -8,45 +8,401 @@ import {
8
8
  discoverSolanaWallets,
9
9
  readSignInMethods,
10
10
  signInOptionsFrom
11
- } from "../chunk-6A6K5RUD.js";
12
- import "../chunk-V5R2DMGY.js";
13
- import "../chunk-MRIURJE3.js";
11
+ } from "../chunk-ZVIRG24J.js";
12
+ import "../chunk-GZ6FJAXN.js";
13
+ import "../chunk-EYH5RDIE.js";
14
14
  import {
15
- RelayError,
16
- RelayErrorCode
17
- } from "../chunk-V7EZUZ3Z.js";
15
+ RelayErrorCode,
16
+ asRelayError,
17
+ isRelayError
18
+ } from "../chunk-36HTX2BM.js";
19
+ import "../chunk-6QJ3LADK.js";
20
+ import "../chunk-NPGENI7K.js";
18
21
  import {
19
22
  sessionMember
20
- } from "../chunk-QBOHLT7M.js";
21
- import "../chunk-VARGF4JS.js";
23
+ } from "../chunk-XFMSUPNS.js";
24
+ import "../chunk-JON5SESL.js";
22
25
 
23
- // src/react/provider.tsx
24
- import { useState as useState3 } from "react";
26
+ // src/react/activity.ts
27
+ import {
28
+ useCallback,
29
+ useEffect,
30
+ useLayoutEffect,
31
+ useRef,
32
+ useState,
33
+ useSyncExternalStore
34
+ } from "react";
35
+
36
+ // src/react/cache.ts
37
+ var LOADING = Object.freeze({
38
+ status: "loading",
39
+ data: void 0,
40
+ error: null
41
+ });
42
+ var UNUSED_MS = 5 * 6e4;
43
+ var STALE_MS = 3e4;
44
+ var relayError = (cause, fallback) => asRelayError(cause, fallback);
45
+ function hashKey(key) {
46
+ return JSON.stringify(key);
47
+ }
48
+ function startsWith(key, prefix) {
49
+ return prefix.every((part, index) => key[index] === part);
50
+ }
51
+ function visible() {
52
+ return typeof document === "undefined" || document.visibilityState !== "hidden";
53
+ }
54
+ var QueryCache = class {
55
+ session;
56
+ #entries = /* @__PURE__ */ new Map();
57
+ #disposed = false;
58
+ #onStaleSession;
59
+ constructor(session, onStaleSession) {
60
+ this.session = session;
61
+ this.#onStaleSession = onStaleSession;
62
+ }
63
+ get disposed() {
64
+ return this.#disposed;
65
+ }
66
+ snapshot(key) {
67
+ return this.#entries.get(hashKey(key))?.state ?? LOADING;
68
+ }
69
+ /** Starts observing `key`, reading it now unless a read is cached or in flight. */
70
+ subscribe(key, read, options, listener) {
71
+ if (this.#disposed) {
72
+ return () => {
73
+ };
74
+ }
75
+ const hash = hashKey(key);
76
+ let entry = this.#entries.get(hash);
77
+ if (!entry) {
78
+ entry = {
79
+ key,
80
+ read,
81
+ state: LOADING,
82
+ listeners: /* @__PURE__ */ new Set(),
83
+ intervals: /* @__PURE__ */ new Map(),
84
+ focus: /* @__PURE__ */ new Set(),
85
+ controller: null,
86
+ pending: null,
87
+ settledAt: 0,
88
+ version: 0,
89
+ timer: void 0,
90
+ unused: void 0
91
+ };
92
+ this.#entries.set(hash, entry);
93
+ }
94
+ const current = entry;
95
+ const token = {};
96
+ const unseen = current.listeners.size === 0;
97
+ current.read = read;
98
+ clearTimeout(current.unused);
99
+ current.listeners.add(listener);
100
+ if (options.intervalMs !== void 0 && options.intervalMs > 0) {
101
+ current.intervals.set(token, options.intervalMs);
102
+ }
103
+ if (options.refetchOnFocus) {
104
+ current.focus.add(token);
105
+ }
106
+ if (!current.pending && (current.settledAt === 0 || unseen && Date.now() - current.settledAt >= STALE_MS)) {
107
+ void this.#fetch(current, false);
108
+ } else {
109
+ this.#schedule(current);
110
+ }
111
+ return () => {
112
+ current.listeners.delete(listener);
113
+ current.intervals.delete(token);
114
+ current.focus.delete(token);
115
+ if (current.listeners.size === 0 && !this.#disposed) {
116
+ clearTimeout(current.timer);
117
+ current.unused = setTimeout(() => {
118
+ if (current.listeners.size === 0) {
119
+ this.#drop(hash, current);
120
+ }
121
+ }, UNUSED_MS);
122
+ }
123
+ };
124
+ }
125
+ /** Reads `key` again now, replacing any read in flight. */
126
+ refresh(key) {
127
+ const entry = this.#entries.get(hashKey(key));
128
+ return entry ? this.#fetch(entry, true) : Promise.resolve();
129
+ }
130
+ /**
131
+ * Marks every query under each prefix out of date: those on screen are read again at once, the
132
+ * rest are dropped. A read in flight is replaced, because it may have started before the change.
133
+ * No prefixes means every query.
134
+ */
135
+ invalidate(prefixes) {
136
+ const reads = [];
137
+ for (const [hash, entry] of this.#entries) {
138
+ if (prefixes.length > 0 && !prefixes.some((prefix) => startsWith(entry.key, prefix))) {
139
+ continue;
140
+ }
141
+ if (entry.listeners.size > 0) {
142
+ reads.push(this.#fetch(entry, true));
143
+ } else {
144
+ this.#drop(hash, entry);
145
+ }
146
+ }
147
+ return Promise.all(reads).then(() => {
148
+ });
149
+ }
150
+ /**
151
+ * Changes the cached value of `key` with `update`, unless a read wrote a newer value since
152
+ * `version` (from `version(key)`). Returns whether it applied.
153
+ */
154
+ update(key, version, update) {
155
+ const entry = this.#entries.get(hashKey(key));
156
+ if (this.#disposed || !entry || entry.version !== version || entry.state.status !== "ready") {
157
+ return false;
158
+ }
159
+ entry.version++;
160
+ this.#write(entry, {
161
+ status: "ready",
162
+ data: update(entry.state.data),
163
+ error: null
164
+ });
165
+ return true;
166
+ }
167
+ /**
168
+ * Replaces the cached value of `key` with one a change returned, cancelling a read in flight that
169
+ * may have started before the change. Does nothing for a key nobody has read.
170
+ */
171
+ set(key, data) {
172
+ const entry = this.#entries.get(hashKey(key));
173
+ if (this.#disposed || !entry) {
174
+ return;
175
+ }
176
+ entry.controller?.abort();
177
+ entry.controller = null;
178
+ entry.pending = null;
179
+ entry.version++;
180
+ entry.settledAt = Date.now();
181
+ this.#write(entry, { status: "ready", data, error: null });
182
+ this.#schedule(entry);
183
+ }
184
+ version(key) {
185
+ return this.#entries.get(hashKey(key))?.version ?? -1;
186
+ }
187
+ /** The tab became visible again: read whatever came due while it was hidden. */
188
+ resume() {
189
+ for (const entry of this.#entries.values()) {
190
+ this.#schedule(entry);
191
+ }
192
+ }
193
+ /** The window regained focus. */
194
+ focus() {
195
+ for (const entry of this.#entries.values()) {
196
+ if (entry.focus.size > 0 && entry.listeners.size > 0) {
197
+ void this.#fetch(entry, false);
198
+ }
199
+ }
200
+ }
201
+ /** Stops every read and timer for good. Answers that arrive later are dropped. */
202
+ dispose() {
203
+ this.#disposed = true;
204
+ for (const [hash, entry] of this.#entries) {
205
+ this.#drop(hash, entry);
206
+ }
207
+ }
208
+ #drop(hash, entry) {
209
+ entry.controller?.abort();
210
+ entry.controller = null;
211
+ entry.pending = null;
212
+ clearTimeout(entry.timer);
213
+ clearTimeout(entry.unused);
214
+ if (this.#entries.get(hash) === entry) {
215
+ this.#entries.delete(hash);
216
+ }
217
+ }
218
+ #write(entry, state) {
219
+ entry.state = state;
220
+ for (const listener of [...entry.listeners]) {
221
+ listener();
222
+ }
223
+ }
224
+ #fetch(entry, replace) {
225
+ if (this.#disposed) {
226
+ return Promise.resolve();
227
+ }
228
+ if (entry.pending && !replace) {
229
+ return entry.pending;
230
+ }
231
+ entry.controller?.abort();
232
+ clearTimeout(entry.timer);
233
+ const controller = new AbortController();
234
+ entry.controller = controller;
235
+ const counts = () => !this.#disposed && entry.controller === controller;
236
+ const pending = (async () => {
237
+ try {
238
+ const data = await Promise.resolve().then(
239
+ () => entry.read({ signal: controller.signal, session: this.session })
240
+ );
241
+ if (counts()) {
242
+ entry.version++;
243
+ this.#write(entry, { status: "ready", data, error: null });
244
+ }
245
+ } catch (cause) {
246
+ if (!counts()) {
247
+ return;
248
+ }
249
+ const error = relayError(cause);
250
+ if (error.code === RelayErrorCode.StaleSession) {
251
+ this.#onStaleSession();
252
+ }
253
+ const gone = error.kind === "http" && error.status === 404 || error.code === RelayErrorCode.StaleSession;
254
+ entry.version++;
255
+ this.#write(
256
+ entry,
257
+ entry.state.status === "ready" && !gone ? { status: "ready", data: entry.state.data, error } : { status: "error", data: void 0, error }
258
+ );
259
+ } finally {
260
+ if (counts()) {
261
+ entry.controller = null;
262
+ entry.pending = null;
263
+ entry.settledAt = Date.now();
264
+ this.#schedule(entry);
265
+ }
266
+ }
267
+ })();
268
+ entry.pending = pending;
269
+ return pending;
270
+ }
271
+ /** Arms the poll for the shortest interval anyone on screen asked for. */
272
+ #schedule(entry) {
273
+ clearTimeout(entry.timer);
274
+ if (this.#disposed || entry.pending || entry.listeners.size === 0 || entry.intervals.size === 0) {
275
+ return;
276
+ }
277
+ const interval = Math.min(...entry.intervals.values());
278
+ const wait = Math.max(0, entry.settledAt + interval - Date.now());
279
+ entry.timer = setTimeout(() => {
280
+ if (visible()) {
281
+ void this.#fetch(entry, false);
282
+ }
283
+ }, wait);
284
+ }
285
+ };
25
286
 
26
287
  // src/react/context.ts
27
288
  import { createContext, useContext } from "react";
28
- var ClientContext = createContext(null);
29
- function useRelay() {
30
- const client = useContext(ClientContext);
31
- if (!client) {
289
+ var StoreContext = createContext(null);
290
+ function useStore() {
291
+ const store = useContext(StoreContext);
292
+ if (!store) {
32
293
  throw new Error("Domino hooks require <RelayProvider>");
33
294
  }
34
- return client;
295
+ return store;
296
+ }
297
+ function useRelay() {
298
+ return useStore().client;
299
+ }
300
+
301
+ // src/react/activity.ts
302
+ function useActivity(options = {}) {
303
+ const store = useStore();
304
+ const scope = useSyncExternalStore(
305
+ store.subscribe,
306
+ () => store.scope,
307
+ () => null
308
+ );
309
+ const session = useSyncExternalStore(
310
+ store.subscribe,
311
+ () => store.session,
312
+ () => LOADING
313
+ );
314
+ const callback = useRef(options.onEvent);
315
+ useLayoutEffect(() => {
316
+ callback.current = options.onEvent;
317
+ }, [options.onEvent]);
318
+ const observer = useRef(null);
319
+ const [state, setState] = useState(null);
320
+ const { cursor, intervalMs } = options;
321
+ useEffect(() => {
322
+ if (!scope?.member) {
323
+ return;
324
+ }
325
+ let active = true;
326
+ const watch = store.client.me.activity.observe(
327
+ (event) => callback.current?.(event),
328
+ {
329
+ cursor,
330
+ intervalMs,
331
+ onState: (observation) => {
332
+ if (!active) {
333
+ return;
334
+ }
335
+ if (observation.kind === "ready") {
336
+ setState((prior) => {
337
+ const items = prior?.owner === scope.key && prior.value.status === "ready" ? prior.value.data.items : [];
338
+ const combined = new Map(
339
+ [...items, ...observation.progress.items].map((event) => [
340
+ event.id,
341
+ event
342
+ ])
343
+ );
344
+ return {
345
+ owner: scope.key,
346
+ value: {
347
+ status: "ready",
348
+ data: {
349
+ ...observation.progress,
350
+ items: [...combined.values()].slice(-100)
351
+ },
352
+ error: null
353
+ }
354
+ };
355
+ });
356
+ } else if (observation.kind === "error") {
357
+ setState((prior) => ({
358
+ owner: scope.key,
359
+ value: prior?.owner === scope.key && prior.value.status === "ready" ? { ...prior.value, error: relayError(observation.error) } : {
360
+ status: "error",
361
+ data: void 0,
362
+ error: relayError(observation.error)
363
+ }
364
+ }));
365
+ } else if (observation.kind === "loading") {
366
+ setState({ owner: scope.key, value: LOADING });
367
+ }
368
+ }
369
+ }
370
+ );
371
+ observer.current = watch;
372
+ return () => {
373
+ active = false;
374
+ watch.dispose();
375
+ if (observer.current === watch) {
376
+ observer.current = null;
377
+ }
378
+ };
379
+ }, [store, scope?.key, cursor, intervalMs]);
380
+ const refresh = useCallback(
381
+ () => observer.current?.refresh() ?? Promise.resolve(),
382
+ []
383
+ );
384
+ const value = session.status === "error" ? session : !scope ? LOADING : !scope.member ? { status: "signed-out", data: void 0, error: null } : state?.owner === scope.key ? state.value : LOADING;
385
+ return { ...value, refresh };
35
386
  }
36
387
 
388
+ // src/react/provider.tsx
389
+ import { useEffect as useEffect3, useMemo as useMemo2 } from "react";
390
+
37
391
  // src/react/experience.tsx
38
392
  import {
39
393
  createContext as createContext2,
394
+ useCallback as useCallback2,
40
395
  useContext as useContext2,
41
- useEffect as useEffect2,
42
396
  useMemo,
43
- useRef,
44
- useState as useState2
397
+ useSyncExternalStore as useSyncExternalStore2
45
398
  } from "react";
46
399
 
47
400
  // src/react/role-delivery.ts
48
401
  function sanitizeDeliveries(deliveries) {
49
- return deliveries.map(({ quest, status }) => ({ quest, status }));
402
+ return deliveries.map(({ quest, status, revokedAt, reconciliation }) => ({
403
+ quest,
404
+ status: reconciliation ? reconciliation.status === "granted" ? "delivered" : reconciliation.status : revokedAt ? "revoked" : status
405
+ }));
50
406
  }
51
407
  function completionMark(quests) {
52
408
  return quests.filter((q) => q.completed || q.completionCount > 0).map((q) => `${q.quest}:${Math.max(q.completionCount, 1)}`).sort().join(",");
@@ -54,19 +410,37 @@ function completionMark(quests) {
54
410
  function hasPending(deliveries) {
55
411
  return deliveries.some((d) => d.status === "pending");
56
412
  }
57
- function roleDeliveryLabel(deliveries, quest) {
58
- const mine = deliveries.filter((d) => d.quest === quest);
413
+ function roleDeliveryStatus(deliveries, quest) {
414
+ const mine = sanitizeDeliveries(deliveries).filter((d) => d.quest === quest);
59
415
  if (!mine.length) {
60
416
  return null;
61
417
  }
62
418
  if (hasPending(mine)) {
63
- return "Role delivery pending";
419
+ return "pending";
420
+ }
421
+ return mine.some((d) => d.status === "failed") ? "failed" : mine.every((d) => d.status === "delivered") ? "granted" : mine.find((d) => d.status !== "delivered")?.status ?? "unknown";
422
+ }
423
+ function roleDeliveryLabel(status) {
424
+ switch (status) {
425
+ case "pending":
426
+ return "Role delivery pending";
427
+ case "failed":
428
+ return "Role delivery failed";
429
+ case "granted":
430
+ return "Role granted";
431
+ case "removed":
432
+ case "revoked":
433
+ return "Role reward revoked";
434
+ case "preserved":
435
+ return "Role managed by the organizer";
436
+ default:
437
+ return status === null ? null : "Role status unavailable";
64
438
  }
65
- return mine.some((d) => d.status === "failed") ? "Role delivery failed" : "Role granted";
66
439
  }
67
440
  var PENDING_POLL_MS = 5e3;
68
441
  var RETRY_DELAYS_MS = [1e3, 4e3];
69
442
  function watchRoleDeliveries(options) {
443
+ const visible2 = options.visible ?? (() => typeof document === "undefined" || document.visibilityState !== "hidden");
70
444
  const schedule = options.schedule ?? ((run2, ms) => {
71
445
  const timer = setTimeout(run2, ms);
72
446
  return () => clearTimeout(timer);
@@ -82,6 +456,10 @@ function watchRoleDeliveries(options) {
82
456
  if (!active || reading) {
83
457
  return;
84
458
  }
459
+ if (!visible2()) {
460
+ resting = true;
461
+ return;
462
+ }
85
463
  reading = true;
86
464
  resting = false;
87
465
  try {
@@ -124,144 +502,330 @@ function watchRoleDeliveries(options) {
124
502
  };
125
503
  }
126
504
 
505
+ // src/react/use-role-deliveries.ts
506
+ import { useEffect as useEffect2, useState as useState2 } from "react";
507
+ var NONE = Object.freeze([]);
508
+ function useRoleDeliveries(relay, member, completions) {
509
+ const [state, setState] = useState2(null);
510
+ useEffect2(() => {
511
+ if (!member || !completions) {
512
+ return;
513
+ }
514
+ const watch = watchRoleDeliveries({
515
+ read: async () => (await relay.me.rewardDeliveries()).deliveries,
516
+ onChange: (deliveries) => setState({ member, deliveries })
517
+ });
518
+ const wake = () => {
519
+ if (document.visibilityState === "visible") {
520
+ watch.wake();
521
+ }
522
+ };
523
+ window.addEventListener("focus", wake);
524
+ document.addEventListener("visibilitychange", wake);
525
+ return () => {
526
+ watch.dispose();
527
+ window.removeEventListener("focus", wake);
528
+ document.removeEventListener("visibilitychange", wake);
529
+ };
530
+ }, [relay, member, completions]);
531
+ return member && completions && state?.member === member ? state.deliveries : NONE;
532
+ }
533
+
534
+ // src/react/experience.tsx
535
+ import { jsx } from "react/jsx-runtime";
536
+ var Deliveries = createContext2(Object.freeze([]));
537
+ function useExperienceSnapshot() {
538
+ const store = useStore();
539
+ const get = () => store.experience;
540
+ return useSyncExternalStore2(store.subscribe, get, get);
541
+ }
542
+ function DeliveriesProvider({ children }) {
543
+ const store = useStore();
544
+ const experience = useExperienceSnapshot();
545
+ const member = useSyncExternalStore2(
546
+ store.subscribe,
547
+ () => store.scope?.member ?? null,
548
+ () => null
549
+ );
550
+ const ready = experience.status === "ready";
551
+ const deliveries = useRoleDeliveries(
552
+ store.client,
553
+ ready ? member : null,
554
+ ready ? completionMark([
555
+ ...experience.data.quests,
556
+ ...experience.data.collections.flatMap((c) => c.quests)
557
+ ]) : ""
558
+ );
559
+ return /* @__PURE__ */ jsx(Deliveries, { value: deliveries, children });
560
+ }
561
+ function useExperience() {
562
+ const store = useStore();
563
+ const experience = useExperienceSnapshot();
564
+ const deliveries = useContext2(Deliveries);
565
+ const refresh = useCallback2(() => store.refreshExperience(), [store]);
566
+ return useMemo(
567
+ () => ({ ...experience, refresh, deliveries }),
568
+ [experience, refresh, deliveries]
569
+ );
570
+ }
571
+
572
+ // src/react/keys.ts
573
+ var relayKeys = {
574
+ /** `useExperience`: invalidating it reads the quest experience again. */
575
+ experience: ["experience"],
576
+ /** `useMe`. */
577
+ me: ["me"],
578
+ /** `useAccounts`. */
579
+ accounts: ["accounts"],
580
+ /** `useWallets`. */
581
+ wallets: ["wallets"],
582
+ /** `useMyProfile`, `useProfile`, `useProfileCompletions`, `useProfileReferral`. */
583
+ profiles: ["profiles"],
584
+ /** `useLeaderboard`, `useLeaderboardPlacement`. */
585
+ leaderboards: ["leaderboards"],
586
+ /** `useReferralSummary`. */
587
+ referrals: ["referrals"],
588
+ /** `usePointLedgers`. */
589
+ pointLedgers: ["point-ledgers"],
590
+ /** `useTieredRewards`. */
591
+ tieredRewards: ["tiered-rewards"],
592
+ /** `useSignInMethods`. */
593
+ signInMethods: ["sign-in-methods"],
594
+ /** `useEntitlements`, `useDeliveries`. */
595
+ rewards: ["rewards"],
596
+ /** `useBalances`, `usePointHistory`. */
597
+ balances: ["balances"],
598
+ /** `useQuestOutcomes`. */
599
+ outcomes: ["outcomes"],
600
+ /** `useOffers`, `useRedeemOffer`. */
601
+ offers: ["offers"]
602
+ };
603
+ var completionChanges = [
604
+ relayKeys.rewards,
605
+ relayKeys.balances,
606
+ relayKeys.leaderboards,
607
+ relayKeys.profiles,
608
+ relayKeys.referrals,
609
+ relayKeys.offers
610
+ ];
611
+
127
612
  // src/react/status.ts
128
613
  function isFinished(view) {
129
- return view.availability.kind === "completed" || view.completion.kind !== "keyed" && view.completed;
614
+ return view.availability.kind === "completed" || view.completion.kind !== "keyed" && view.completion.kind !== "progress" && view.completed;
130
615
  }
131
616
  function isAutomatic(view) {
132
617
  return view.trigger.kind === "automatic";
133
618
  }
134
619
  function runsSilently(view) {
135
- return view.trigger.kind === "automatic" && (view.completion.kind === "keyed" || Boolean(view.trigger.observation));
620
+ return view.trigger.kind === "automatic" && (view.completion.kind === "keyed" || Boolean(view.trigger.tracker));
136
621
  }
137
- function completionCountLabel(view) {
138
- if (view.completion.kind !== "keyed" || view.completionCount < 1) {
622
+ function completionCountText(view) {
623
+ if (view.completion.kind !== "keyed" && view.completion.kind !== "progress" || view.completionCount < 1) {
139
624
  return null;
140
625
  }
141
626
  return view.completionCount === 1 ? "Completed once" : `Completed ${view.completionCount.toLocaleString()} times`;
142
627
  }
143
628
  function canRepeat(view) {
144
- return view.completion.kind === "keyed" && view.attempt?.status === "accepted" && canSubmit(view);
629
+ return (view.completion.kind === "keyed" || view.completion.kind === "progress") && view.attempt?.status === "accepted" && canSubmit(view);
145
630
  }
146
631
  function visibleQuests(quests) {
147
632
  return quests.filter((q) => q.visibility !== "hidden");
148
633
  }
149
634
  function questStatus(view) {
635
+ return knownStatus(view);
636
+ }
637
+ function knownStatus(view) {
150
638
  if (isFinished(view)) {
151
- return { tone: "done", label: "Completed" };
639
+ return { tone: "done", code: "completed" };
152
640
  }
153
641
  if (view.lifecycle === "paused") {
154
- return {
155
- tone: "off",
156
- label: "Paused",
157
- detail: "This activity is paused for now."
158
- };
642
+ return { tone: "off", code: "paused" };
159
643
  }
160
644
  if (view.lifecycle === "archived") {
645
+ return { tone: "off", code: "closed" };
646
+ }
647
+ if (view.attempt?.status === "deferred") {
161
648
  return {
162
- tone: "off",
163
- label: "Closed",
164
- detail: "This activity is no longer running."
649
+ tone: "waiting",
650
+ code: view.attempt.code === "evaluator-progress" ? "evaluator-progress" : "evaluator-deferred",
651
+ reason: view.attempt.reason ?? ""
165
652
  };
166
653
  }
167
654
  const availability = view.availability;
655
+ if (availability.kind === "blocked") {
656
+ return {
657
+ tone: availability.reason === "paused" || availability.reason === "outside-window" ? "off" : availability.reason === "budget-exhausted" ? "attention" : "locked",
658
+ code: availability.reason
659
+ };
660
+ }
168
661
  const ownSubmission = view.trigger.kind === "manual" && view.trigger.actor !== "staff";
169
662
  const submissionFirst = ownSubmission && (availability.kind === "checking" || availability.kind === "needs-review" || availability.kind === "retryable");
170
- if (view.streak && availability.kind !== "locked" && !submissionFirst) {
171
- const streak = view.streak;
172
- const rejection = ownSubmission && view.attempt?.status === "rejected" ? sentence(view.attempt.reason ?? "That submission wasn't accepted.") : null;
173
- if (streak.milestone.status === "budget-exhausted") {
174
- return {
175
- tone: "attention",
176
- label: "Milestone reached",
177
- detail: rejection ?? "Your qualifying days are saved. This milestone's reward budget was exhausted."
178
- };
663
+ if (view.counter && availability.kind !== "locked" && !submissionFirst) {
664
+ const currentAchievement = view.counter.latest?.window === view.counter.window.key ? view.counter.latest : null;
665
+ if (currentAchievement?.status === "revoked") {
666
+ return { tone: "attention", code: "counter-revoked" };
179
667
  }
180
- const deadline = new Intl.DateTimeFormat("en", {
181
- timeZone: streak.timeZone,
182
- month: "short",
183
- day: "numeric",
184
- hour: "numeric",
185
- minute: "2-digit",
186
- timeZoneName: "short"
187
- }).format(streak.nextBoundary);
188
- const progress = 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.`;
189
668
  return {
190
- tone: rejection ? "attention" : streak.todayQualified ? "waiting" : "open",
191
- label: `${streak.current} / ${streak.goal} days`,
192
- detail: rejection ? `${rejection} ${progress}` : progress
669
+ tone: currentAchievement?.status === "budget-exhausted" ? "attention" : ownSubmission ? "open" : "waiting",
670
+ code: currentAchievement?.status === "budget-exhausted" ? "counter-budget-exhausted" : "counter"
193
671
  };
194
672
  }
195
- if (runsSilently(view) && availability.kind !== "locked") {
673
+ if (view.streak && availability.kind !== "locked" && !submissionFirst) {
674
+ const rejected = ownSubmission && view.attempt?.status === "rejected" ? { reason: view.attempt.reason ?? "" } : {};
675
+ if (view.streak.milestone.status === "budget-exhausted") {
676
+ return { tone: "attention", code: "streak-budget-exhausted", ...rejected };
677
+ }
196
678
  return {
197
- tone: "waiting",
198
- label: "Automatic",
199
- detail: view.completion.kind === "keyed" ? [
200
- completionCountLabel(view),
201
- "This counts by itself each time you qualify."
202
- ].filter(Boolean).join(". ") : "This updates by itself when you qualify."
679
+ tone: "reason" in rejected ? "attention" : view.streak.todayQualified ? "waiting" : "open",
680
+ code: "streak",
681
+ ...rejected
203
682
  };
204
683
  }
684
+ if (runsSilently(view) && availability.kind !== "locked") {
685
+ return { tone: "waiting", code: "automatic" };
686
+ }
205
687
  if (isAutomatic(view) && availability.kind !== "locked") {
206
- return {
207
- tone: "waiting",
208
- label: "In progress",
209
- detail: "This updates by itself when you qualify."
210
- };
688
+ return { tone: "waiting", code: "in-progress" };
211
689
  }
212
690
  switch (availability.kind) {
213
691
  case "completed":
214
- return { tone: "done", label: "Completed" };
692
+ return { tone: "done", code: "completed" };
215
693
  case "locked":
216
- return {
217
- tone: "locked",
218
- label: "Locked",
219
- detail: `Complete ${list(availability.missing.map((m) => m.title))} first.`
220
- };
694
+ return { tone: "locked", code: "locked" };
221
695
  case "checking":
222
- return {
223
- tone: "waiting",
224
- label: "Checking",
225
- detail: "Your submission is being verified."
226
- };
696
+ return { tone: "waiting", code: "checking" };
227
697
  case "needs-review":
228
- return {
229
- tone: "waiting",
230
- label: "In review",
231
- detail: "A person is reviewing your submission."
232
- };
698
+ return { tone: "waiting", code: "in-review" };
233
699
  case "retryable":
234
- return {
235
- tone: "attention",
236
- label: "Didn't go through",
237
- detail: "Verification couldn't run. Try again. Nothing was lost."
238
- };
700
+ return { tone: "attention", code: "retryable" };
239
701
  case "staff-required":
240
- return {
241
- tone: "waiting",
242
- label: "Waiting for staff",
243
- detail: "A staff member confirms this activity for you."
244
- };
702
+ return { tone: "waiting", code: "staff-required" };
245
703
  case "automatic":
246
- return {
247
- tone: "waiting",
248
- label: "In progress",
249
- detail: "This updates by itself when you qualify."
250
- };
704
+ return { tone: "waiting", code: "in-progress" };
251
705
  case "available":
252
706
  return view.attempt?.status === "rejected" ? {
253
707
  tone: "attention",
254
- label: "Try again",
255
- detail: view.attempt.reason ?? "That submission wasn't accepted."
256
- } : { tone: "open", label: "Ready" };
708
+ code: "rejected",
709
+ reason: view.attempt.reason ?? ""
710
+ } : { tone: "open", code: "ready" };
711
+ }
712
+ }
713
+ function questStatusText(view) {
714
+ const status = knownStatus(view);
715
+ const words = (label, detail) => detail === void 0 ? { ...status, label } : { ...status, label, detail };
716
+ const rejection = status.reason === void 0 ? null : sentence(status.reason || "That submission wasn't accepted.");
717
+ switch (status.code) {
718
+ case "completed":
719
+ return words("Completed");
720
+ case "paused":
721
+ return words("Paused", "This activity is paused for now.");
722
+ case "closed":
723
+ return words("Closed", "This activity is no longer running.");
724
+ case "counter":
725
+ return words(
726
+ `${view.counter?.current ?? 0} / ${view.counter?.goal ?? 0} actions`,
727
+ "Distinct qualifying actions count toward this goal."
728
+ );
729
+ case "counter-revoked":
730
+ return words(
731
+ "Achievement revoked",
732
+ "The reward was corrected. Its qualifying actions remain recorded."
733
+ );
734
+ case "counter-budget-exhausted":
735
+ return words(
736
+ "Goal reached; budget exhausted",
737
+ "Your qualifying actions are saved. This achievement did not grant a reward."
738
+ );
739
+ case "streak-budget-exhausted":
740
+ return words(
741
+ "Milestone reached",
742
+ rejection ?? "Your qualifying days are saved. This milestone's reward budget was exhausted."
743
+ );
744
+ case "streak": {
745
+ const streak = view.streak;
746
+ const deadline = new Intl.DateTimeFormat("en", {
747
+ timeZone: streak.timeZone,
748
+ month: "short",
749
+ day: "numeric",
750
+ hour: "numeric",
751
+ minute: "2-digit",
752
+ timeZoneName: "short"
753
+ }).format(streak.nextBoundary);
754
+ const progress = streak.todayQualified ? `Today counts. Your next 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.`;
755
+ return words(
756
+ `${streak.current} / ${streak.goal} days`,
757
+ rejection ? `${rejection} ${progress}` : progress
758
+ );
759
+ }
760
+ case "automatic":
761
+ return words(
762
+ "Automatic",
763
+ view.completion.kind === "keyed" ? [
764
+ completionCountText(view),
765
+ "This counts by itself each time you qualify."
766
+ ].filter(Boolean).join(". ") : "This updates by itself when you qualify."
767
+ );
768
+ case "in-progress":
769
+ return words("In progress", "This updates by itself when you qualify.");
770
+ case "locked":
771
+ return words(
772
+ "Locked",
773
+ view.availability.kind === "locked" ? sentence(
774
+ capitalize(list(view.availability.missing.map(prerequisiteText)))
775
+ ) : void 0
776
+ );
777
+ case "budget-exhausted":
778
+ return words(
779
+ "Budget reached",
780
+ view.availability.kind === "blocked" && view.availability.resetsAt !== void 0 ? `This quest's reward budget resets at ${new Date(view.availability.resetsAt).toISOString()}.` : "This quest's reward budget has been used."
781
+ );
782
+ case "connection-required":
783
+ return words("Connect an account", "Connect the required account first.");
784
+ case "wallet-required":
785
+ return words("Connect a wallet", "Connect the required wallet first.");
786
+ case "outside-window":
787
+ return words(
788
+ "Not open",
789
+ view.availability.kind === "blocked" && view.availability.resetsAt !== void 0 ? `This quest opens at ${new Date(view.availability.resetsAt).toISOString()}.` : "This quest's availability window has ended."
790
+ );
791
+ case "enrollment-required":
792
+ return words(
793
+ "Enroll",
794
+ view.campaign ? `Enroll in ${quote(view.campaign.title)} first.` : "Enroll in this campaign first."
795
+ );
796
+ case "evaluator-deferred":
797
+ return words("Waiting to check again", status.reason);
798
+ case "evaluator-progress":
799
+ return words("In progress", status.reason);
800
+ case "checking":
801
+ return words("Checking", "Your submission is being verified.");
802
+ case "in-review":
803
+ return words("In review", "A person is reviewing your submission.");
804
+ case "retryable":
805
+ return words(
806
+ "Didn't go through",
807
+ "Verification couldn't run. Try again. Nothing was lost."
808
+ );
809
+ case "staff-required":
810
+ return words(
811
+ "Waiting for staff",
812
+ "A staff member confirms this activity for you."
813
+ );
814
+ case "rejected":
815
+ return words(
816
+ "Try again",
817
+ status.reason || "That submission wasn't accepted."
818
+ );
819
+ case "ready":
820
+ return words("Ready");
257
821
  }
258
822
  }
259
823
  function canSubmit(view) {
260
- return !isFinished(view) && !isAutomatic(view) && view.lifecycle === "active" && view.availability.kind === "available";
824
+ return !isFinished(view) && !(view.counter?.repeat === "once" && view.counter.settled > 0) && !isAutomatic(view) && view.lifecycle === "active" && view.availability.kind === "available";
261
825
  }
262
826
  function progressOf(quests) {
263
827
  const counted = quests.filter(
264
- (q) => q.lifecycle !== "archived" && q.completion.kind !== "keyed"
828
+ (q) => q.lifecycle !== "archived" && q.completion.kind !== "keyed" && q.completion.kind !== "progress"
265
829
  );
266
830
  return {
267
831
  total: counted.length,
@@ -273,114 +837,321 @@ function sentence(text) {
273
837
  return /[.!?]$/.test(trimmed) ? trimmed : `${trimmed}.`;
274
838
  }
275
839
  function list(items) {
276
- return new Intl.ListFormat("en", { type: "conjunction" }).format(
277
- items.map((item) => `\u201C${item}\u201D`)
278
- );
840
+ return new Intl.ListFormat("en", { type: "conjunction" }).format(items);
841
+ }
842
+ function prerequisiteText(prerequisite) {
843
+ switch (prerequisite.kind) {
844
+ case "completion":
845
+ return prerequisite.scope === "ever" ? `complete ${quote(prerequisite.title)} first` : `complete ${quote(prerequisite.title)} in this ${prerequisite.period}`;
846
+ case "balance":
847
+ return `hold ${prerequisite.minimum.toLocaleString()} ${prerequisite.title}`;
848
+ case "group":
849
+ return `join ${quote(prerequisite.title)}`;
850
+ }
851
+ }
852
+ function capitalize(value) {
853
+ return value ? value[0].toUpperCase() + value.slice(1) : value;
854
+ }
855
+ function quote(value) {
856
+ return `\u201C${value}\u201D`;
279
857
  }
280
858
 
281
- // src/react/use-role-deliveries.ts
282
- import { useEffect, useState } from "react";
283
- function useRoleDeliveries(relay, member, completions) {
284
- const [state, setState] = useState(null);
285
- useEffect(() => {
286
- if (!member || !completions) {
287
- return;
859
+ // src/react/store.ts
860
+ var SIGNED_OUT = Object.freeze({
861
+ status: "signed-out",
862
+ data: void 0,
863
+ error: null
864
+ });
865
+ function scopeKey(session) {
866
+ if (!session) {
867
+ return "visitor";
868
+ }
869
+ const principal = session.principal;
870
+ return JSON.stringify([
871
+ session.organization,
872
+ session.project,
873
+ session.environment,
874
+ principal.kind,
875
+ sessionMember(session) ?? principal.subject ?? null,
876
+ session.id
877
+ ]);
878
+ }
879
+ function isVisible() {
880
+ return typeof document === "undefined" || document.visibilityState !== "hidden";
881
+ }
882
+ var RelayStore = class {
883
+ client;
884
+ #session = LOADING;
885
+ #scope = null;
886
+ #experience = { owner: null, state: LOADING };
887
+ #experienceView = LOADING;
888
+ #listeners = /* @__PURE__ */ new Set();
889
+ #observer = null;
890
+ #starting = false;
891
+ #mounted = false;
892
+ /** Bumped by every session the store learns of, so an older read cannot overwrite a newer one. */
893
+ #sessionVersion = 0;
894
+ #sessionRead = null;
895
+ /** The completions the experience last showed, and for whom. */
896
+ #completions = null;
897
+ constructor(client) {
898
+ this.client = client;
899
+ }
900
+ /** `refreshSession` with one identity per store, for hooks to hand out as `refresh`. */
901
+ rereadSession = () => this.refreshSession();
902
+ subscribe = (listener) => {
903
+ this.#listeners.add(listener);
904
+ return () => {
905
+ this.#listeners.delete(listener);
906
+ };
907
+ };
908
+ get session() {
909
+ return this.#session;
910
+ }
911
+ get scope() {
912
+ return this.#scope;
913
+ }
914
+ get experience() {
915
+ return this.#experienceView;
916
+ }
917
+ /** Starts observing; the returned function stops every read, timer and listener. */
918
+ mount() {
919
+ this.#mounted = true;
920
+ if (isVisible()) {
921
+ this.#startObserver();
288
922
  }
289
- const watch = watchRoleDeliveries({
290
- read: async () => (await relay.me.rewardDeliveries()).deliveries,
291
- onChange: (deliveries) => setState({ member, deliveries })
292
- });
293
- const wake = () => {
294
- if (document.visibilityState === "visible") {
295
- watch.wake();
923
+ void this.refreshSession();
924
+ const onVisibility = () => {
925
+ if (isVisible()) {
926
+ this.#startObserver();
927
+ this.#scope?.cache.resume();
928
+ } else {
929
+ this.#observer?.dispose();
930
+ this.#observer = null;
296
931
  }
297
932
  };
298
- window.addEventListener("focus", wake);
299
- document.addEventListener("visibilitychange", wake);
933
+ const onFocus = () => this.#scope?.cache.focus();
934
+ if (typeof window !== "undefined") {
935
+ document.addEventListener("visibilitychange", onVisibility);
936
+ window.addEventListener("focus", onFocus);
937
+ }
300
938
  return () => {
301
- watch.dispose();
302
- window.removeEventListener("focus", wake);
303
- document.removeEventListener("visibilitychange", wake);
939
+ this.#mounted = false;
940
+ if (typeof window !== "undefined") {
941
+ document.removeEventListener("visibilitychange", onVisibility);
942
+ window.removeEventListener("focus", onFocus);
943
+ }
944
+ this.#observer?.dispose();
945
+ this.#observer = null;
946
+ this.#sessionRead?.abort();
947
+ this.#sessionRead = null;
948
+ this.#sessionVersion++;
949
+ this.#scope?.cache.dispose();
950
+ this.#scope = null;
951
+ this.#session = LOADING;
952
+ this.#experience = { owner: null, state: LOADING };
953
+ this.#emit();
304
954
  };
305
- }, [relay, member, completions]);
306
- return member && completions && state?.member === member ? state.deliveries : [];
307
- }
308
-
309
- // src/react/experience.tsx
310
- import { jsx } from "react/jsx-runtime";
311
- var Context = createContext2(null);
312
- function ExperienceProvider({ children }) {
313
- const relay = useRelay();
314
- const [experience, setExperience] = useState2({
315
- status: "loading"
316
- });
317
- const [owner, setOwner] = useState2(null);
318
- const [member, setMember] = useState2(null);
319
- const observer = useRef(null);
320
- useEffect2(() => {
321
- const subscription = relay.quests.observe((observation) => {
322
- if (observation.kind === "session" || observation.kind === "ready") {
323
- setMember(sessionMember(observation.session));
324
- } else if (observation.kind === "loading") {
325
- setMember(null);
955
+ }
956
+ /** Reads the session again now, without waiting for the experience observer. */
957
+ async refreshSession() {
958
+ if (!this.#mounted) {
959
+ return;
960
+ }
961
+ const version = ++this.#sessionVersion;
962
+ this.#sessionRead?.abort();
963
+ const controller = new AbortController();
964
+ this.#sessionRead = controller;
965
+ try {
966
+ const session = await this.client.auth.session({
967
+ signal: controller.signal
968
+ });
969
+ if (version === this.#sessionVersion && this.#mounted) {
970
+ const before = this.#scope?.key;
971
+ this.#setSession({ status: "ready", data: session, error: null });
972
+ this.#emit();
973
+ if (before !== void 0 && this.#scope?.key !== before) {
974
+ void this.#observer?.refresh();
975
+ }
976
+ }
977
+ } catch (cause) {
978
+ if (version === this.#sessionVersion && this.#mounted) {
979
+ this.#failSession(relayError(cause));
980
+ this.#emit();
326
981
  }
327
- if (observation.kind === "ready") {
328
- setOwner(observation.progress.member);
982
+ } finally {
983
+ if (this.#sessionRead === controller) {
984
+ this.#sessionRead = null;
329
985
  }
330
- setExperience((previous) => {
986
+ }
987
+ }
988
+ /**
989
+ * Signs the participant out through the client, and drops their scope as soon as Relay confirms:
990
+ * the previous member's values never wait on the next session read, which may fail. Only the
991
+ * scope that signed out is dropped: someone who signed in while the request was in flight stays.
992
+ */
993
+ async signOut() {
994
+ const scope = this.#scope;
995
+ await this.client.auth.signOut();
996
+ if (!this.#mounted || this.#scope !== scope) {
997
+ return;
998
+ }
999
+ this.#sessionVersion++;
1000
+ this.#sessionRead?.abort();
1001
+ this.#setSession({ status: "ready", data: null, error: null });
1002
+ this.#emit();
1003
+ }
1004
+ refreshExperience() {
1005
+ return this.#observer?.refresh() ?? Promise.resolve();
1006
+ }
1007
+ /**
1008
+ * Marks queries out of date (see `QueryCache.invalidate`). The `["experience"]` key, or no keys at
1009
+ * all, also reads the quest experience again.
1010
+ */
1011
+ invalidate(keys) {
1012
+ const reads = [];
1013
+ if (keys.length === 0 || keys.some((key) => key[0] === "experience")) {
1014
+ reads.push(this.refreshExperience());
1015
+ }
1016
+ reads.push(this.#scope?.cache.invalidate(keys) ?? Promise.resolve());
1017
+ return Promise.all(reads).then(() => {
1018
+ });
1019
+ }
1020
+ #startObserver() {
1021
+ if (this.#observer || !this.#mounted) {
1022
+ return;
1023
+ }
1024
+ this.#starting = true;
1025
+ try {
1026
+ this.#observer = this.client.quests.observe((observation) => {
331
1027
  switch (observation.kind) {
332
1028
  case "loading":
333
- return { status: "loading" };
1029
+ if (this.#starting) {
1030
+ return;
1031
+ }
1032
+ this.#sessionVersion++;
1033
+ this.#sessionRead?.abort();
1034
+ return;
334
1035
  case "session":
335
- return observation.session?.principal.kind === "member" ? previous : { status: "signed-out" };
1036
+ this.#sessionVersion++;
1037
+ this.#setSession({
1038
+ status: "ready",
1039
+ data: observation.session,
1040
+ error: null
1041
+ });
1042
+ break;
336
1043
  case "ready":
337
- return {
1044
+ this.#sessionVersion++;
1045
+ this.#setSession({
338
1046
  status: "ready",
339
- // Hidden quests never reach the UI, on any poll.
340
- quests: visibleQuests(observation.progress.quests),
341
- collections: observation.progress.collections.map(
342
- (collection) => ({
343
- ...collection,
344
- quests: visibleQuests(collection.quests)
345
- })
346
- ),
347
- stale: null
1047
+ data: observation.session,
1048
+ error: null
1049
+ });
1050
+ this.#completed(observation.progress);
1051
+ this.#experience = {
1052
+ owner: observation.progress.member,
1053
+ state: {
1054
+ status: "ready",
1055
+ // Hidden quests never reach the UI, on any poll.
1056
+ data: {
1057
+ entitlements: observation.progress.entitlements,
1058
+ quests: visibleQuests(observation.progress.quests),
1059
+ collections: observation.progress.collections.map(
1060
+ (collection) => ({
1061
+ ...collection,
1062
+ quests: visibleQuests(collection.quests)
1063
+ })
1064
+ )
1065
+ },
1066
+ error: null
1067
+ }
1068
+ };
1069
+ break;
1070
+ case "error": {
1071
+ const error = relayError(observation.error);
1072
+ const previous = this.#experience;
1073
+ this.#experience = previous.state.status === "ready" ? {
1074
+ owner: previous.owner,
1075
+ state: { ...previous.state, error }
1076
+ } : {
1077
+ owner: this.#scope?.member ?? null,
1078
+ state: { status: "error", data: void 0, error }
348
1079
  };
349
- case "error":
350
- return previous.status === "ready" ? { ...previous, stale: observation.error.message } : { status: "error", message: observation.error.message };
1080
+ if (this.#session.status === "loading") {
1081
+ void this.refreshSession();
1082
+ }
1083
+ break;
1084
+ }
351
1085
  }
1086
+ this.#emit();
352
1087
  });
353
- });
354
- observer.current = subscription;
355
- return () => subscription.dispose();
356
- }, [relay]);
357
- const mine = experience.status === "ready" && member !== null && member === owner;
358
- const deliveries = useRoleDeliveries(
359
- relay,
360
- mine ? member : null,
361
- mine ? completionMark([
362
- ...experience.quests,
363
- ...experience.collections.flatMap((c) => c.quests)
364
- ]) : ""
365
- );
366
- const value = useMemo(
367
- () => ({
368
- experience,
369
- member: mine ? member : null,
370
- deliveries,
371
- refresh: async () => observer.current?.refresh()
372
- }),
373
- [experience, mine, member, deliveries]
374
- );
375
- return /* @__PURE__ */ jsx(Context, { value, children });
376
- }
377
- function useExperience() {
378
- const value = useContext2(Context);
379
- if (!value) {
380
- throw new Error("useExperience requires <RelayProvider>");
1088
+ } finally {
1089
+ this.#starting = false;
1090
+ }
381
1091
  }
382
- return value;
383
- }
1092
+ /**
1093
+ * A new completion changes points, scores, profile completions and referral progress, whether
1094
+ * the participant submitted it or Domino completed it by itself: read them again.
1095
+ */
1096
+ #completed(progress) {
1097
+ const mark = completionMark([
1098
+ ...progress.quests,
1099
+ ...progress.collections.flatMap((collection) => collection.quests)
1100
+ ]);
1101
+ const previous = this.#completions;
1102
+ this.#completions = { member: progress.member, mark };
1103
+ if (previous?.member === progress.member && previous.mark !== mark) {
1104
+ void this.#scope?.cache.invalidate(completionChanges);
1105
+ }
1106
+ }
1107
+ #failSession(error) {
1108
+ this.#setSession(
1109
+ this.#session.status === "ready" ? { ...this.#session, error } : { status: "error", data: void 0, error }
1110
+ );
1111
+ }
1112
+ #setSession(next) {
1113
+ this.#session = next;
1114
+ const key = next.status === "loading" ? null : next.status === "error" ? "unknown" : scopeKey(next.data);
1115
+ if (key === (this.#scope?.key ?? null)) {
1116
+ return;
1117
+ }
1118
+ this.#scope?.cache.dispose();
1119
+ if (key === null) {
1120
+ this.#scope = null;
1121
+ return;
1122
+ }
1123
+ const session = next.status === "ready" ? next.data : null;
1124
+ this.#scope = {
1125
+ key,
1126
+ session,
1127
+ member: sessionMember(session),
1128
+ cache: new QueryCache(session?.id, () => void this.refreshSession())
1129
+ };
1130
+ }
1131
+ #deriveExperience() {
1132
+ const session = this.#session;
1133
+ if (session.status === "loading") {
1134
+ return LOADING;
1135
+ }
1136
+ if (session.status === "error") {
1137
+ return session;
1138
+ }
1139
+ const member = this.#scope?.member ?? null;
1140
+ if (!member) {
1141
+ return SIGNED_OUT;
1142
+ }
1143
+ return this.#experience.owner === member ? this.#experience.state : LOADING;
1144
+ }
1145
+ #emit() {
1146
+ const experience = this.#deriveExperience();
1147
+ if (experience !== this.#experienceView) {
1148
+ this.#experienceView = experience;
1149
+ }
1150
+ for (const listener of [...this.#listeners]) {
1151
+ listener();
1152
+ }
1153
+ }
1154
+ };
384
1155
 
385
1156
  // src/react/provider.tsx
386
1157
  import { jsx as jsx2 } from "react/jsx-runtime";
@@ -389,10 +1160,252 @@ function RelayProvider({
389
1160
  baseUrl = "/relay",
390
1161
  children
391
1162
  }) {
392
- const [value] = useState3(
393
- () => client ?? createBrowserRelayClient({ baseUrl })
1163
+ const origin = client ? null : baseUrl;
1164
+ const store = useMemo2(
1165
+ () => new RelayStore(
1166
+ client ?? createBrowserRelayClient({ baseUrl: origin ?? baseUrl })
1167
+ ),
1168
+ // A given client ignores `baseUrl`.
1169
+ [client, origin]
1170
+ );
1171
+ useEffect3(() => store.mount(), [store]);
1172
+ return /* @__PURE__ */ jsx2(StoreContext, { value: store, children: /* @__PURE__ */ jsx2(DeliveriesProvider, { children }) });
1173
+ }
1174
+
1175
+ // src/react/query.ts
1176
+ import {
1177
+ useCallback as useCallback3,
1178
+ useEffect as useEffect4,
1179
+ useLayoutEffect as useLayoutEffect2,
1180
+ useMemo as useMemo3,
1181
+ useRef as useRef2,
1182
+ useState as useState3,
1183
+ useSyncExternalStore as useSyncExternalStore3
1184
+ } from "react";
1185
+ function useLatest(value) {
1186
+ const ref = useRef2(value);
1187
+ useLayoutEffect2(() => {
1188
+ ref.current = value;
1189
+ });
1190
+ return ref;
1191
+ }
1192
+ function useSessionSnapshot(store) {
1193
+ const get = () => store.session;
1194
+ return useSyncExternalStore3(store.subscribe, get, get);
1195
+ }
1196
+ function useScope(store) {
1197
+ const get = () => store.scope;
1198
+ return useSyncExternalStore3(store.subscribe, get, get);
1199
+ }
1200
+ function useCached(cache, key, read, options) {
1201
+ const hash = key ? hashKey(key) : null;
1202
+ const { intervalMs, refetchOnFocus } = options;
1203
+ const latest = useLatest({ key, read });
1204
+ const subscribe = useCallback3(
1205
+ (listener) => {
1206
+ const { key: current } = latest.current;
1207
+ if (!cache || !current) {
1208
+ return () => {
1209
+ };
1210
+ }
1211
+ return cache.subscribe(
1212
+ current,
1213
+ (context) => latest.current.read(context),
1214
+ { intervalMs, refetchOnFocus },
1215
+ listener
1216
+ );
1217
+ },
1218
+ // `hash` stands for the key.
1219
+ [cache, hash, intervalMs, refetchOnFocus]
1220
+ );
1221
+ const get = () => cache && key ? cache.snapshot(key) : LOADING;
1222
+ const snapshot = useSyncExternalStore3(subscribe, get, get);
1223
+ const refresh = useCallback3(
1224
+ () => cache && latest.current.key ? cache.refresh(latest.current.key) : Promise.resolve(),
1225
+ [cache, hash]
1226
+ );
1227
+ return useMemo3(() => ({ ...snapshot, refresh }), [snapshot, refresh]);
1228
+ }
1229
+ function useRelayQuery(key, read, options = {}) {
1230
+ const store = useStore();
1231
+ const scope = useScope(store);
1232
+ return useCached(scope?.cache ?? null, key, read, options);
1233
+ }
1234
+ function useMemberQuery(key, read, options = {}) {
1235
+ const store = useStore();
1236
+ const scope = useScope(store);
1237
+ const member = scope?.member ?? null;
1238
+ const query = useCached(
1239
+ member ? scope.cache : null,
1240
+ member ? key : null,
1241
+ read,
1242
+ options
1243
+ );
1244
+ return useMemberGate(store, member, query);
1245
+ }
1246
+ function useMemberGate(store, member, query) {
1247
+ const session = useSessionSnapshot(store);
1248
+ return useMemo3(() => {
1249
+ if (session.status === "loading") {
1250
+ return { ...LOADING, refresh: query.refresh };
1251
+ }
1252
+ if (session.status === "error") {
1253
+ return {
1254
+ status: "error",
1255
+ data: void 0,
1256
+ error: session.error,
1257
+ refresh: store.rereadSession
1258
+ };
1259
+ }
1260
+ return member ? query : { ...SIGNED_OUT, refresh: query.refresh };
1261
+ }, [session, member, query, store]);
1262
+ }
1263
+ function usePages(cache, key, read, options) {
1264
+ const hash = key ? hashKey(key) : null;
1265
+ const query = useCached(
1266
+ cache,
1267
+ key,
1268
+ (context) => read(context),
1269
+ options
1270
+ );
1271
+ const [more, setMore] = useState3(null);
1272
+ const busy = useRef2(null);
1273
+ const latest = useLatest({ key, read, query });
1274
+ useEffect4(
1275
+ () => () => {
1276
+ busy.current?.abort();
1277
+ busy.current = null;
1278
+ },
1279
+ [cache, hash]
1280
+ );
1281
+ const loadMore = useCallback3(async () => {
1282
+ const { key: current, read: reader, query: shown } = latest.current;
1283
+ if (!cache || !current || busy.current || shown.status !== "ready" || shown.data.nextCursor === null) {
1284
+ return;
1285
+ }
1286
+ const owner = { cache, hash };
1287
+ const version = cache.version(current);
1288
+ const controller = new AbortController();
1289
+ busy.current = controller;
1290
+ setMore({ owner, loading: true, error: null });
1291
+ try {
1292
+ const page = await reader({
1293
+ signal: controller.signal,
1294
+ session: cache.session,
1295
+ cursor: shown.data.nextCursor
1296
+ });
1297
+ if (busy.current === controller) {
1298
+ cache.update(current, version, (data) => ({
1299
+ items: [...data.items, ...page.items],
1300
+ nextCursor: page.nextCursor
1301
+ }));
1302
+ }
1303
+ } catch (cause) {
1304
+ if (busy.current === controller) {
1305
+ setMore({ owner, loading: false, error: relayError(cause) });
1306
+ }
1307
+ } finally {
1308
+ if (busy.current === controller) {
1309
+ busy.current = null;
1310
+ setMore(
1311
+ (value) => value?.owner === owner ? { ...value, loading: false } : value
1312
+ );
1313
+ }
1314
+ }
1315
+ }, [cache, hash]);
1316
+ const mine = more && more.owner.cache === cache && more.owner.hash === hash ? more : null;
1317
+ return useMemo3(
1318
+ () => ({
1319
+ ...query,
1320
+ loadMore,
1321
+ loadingMore: mine?.loading ?? false,
1322
+ loadMoreError: mine?.error ?? null
1323
+ }),
1324
+ [query, loadMore, mine]
1325
+ );
1326
+ }
1327
+ function useRelayPages(key, read, options = {}) {
1328
+ const store = useStore();
1329
+ const scope = useScope(store);
1330
+ return usePages(scope?.cache ?? null, key, read, options);
1331
+ }
1332
+ function useMemberPages(key, read, options = {}) {
1333
+ const store = useStore();
1334
+ const scope = useScope(store);
1335
+ const member = scope?.member ?? null;
1336
+ const pages = usePages(
1337
+ member ? scope.cache : null,
1338
+ member ? key : null,
1339
+ read,
1340
+ options
1341
+ );
1342
+ const gate = useMemberGate(store, member, pages);
1343
+ return useMemo3(
1344
+ () => gate === pages ? pages : {
1345
+ ...gate,
1346
+ loadMore: pages.loadMore,
1347
+ loadingMore: false,
1348
+ loadMoreError: null
1349
+ },
1350
+ [gate, pages]
1351
+ );
1352
+ }
1353
+ function useInvalidate() {
1354
+ const store = useStore();
1355
+ return useCallback3((keys = []) => store.invalidate(keys), [store]);
1356
+ }
1357
+ function useSession() {
1358
+ const store = useStore();
1359
+ const session = useSessionSnapshot(store);
1360
+ const refresh = store.rereadSession;
1361
+ return useMemo3(
1362
+ () => ({
1363
+ ...session,
1364
+ refresh,
1365
+ member: session.status === "ready" ? sessionMember(session.data) : null
1366
+ }),
1367
+ [session, refresh]
1368
+ );
1369
+ }
1370
+ function useAction() {
1371
+ const [busy, setBusy] = useState3(false);
1372
+ const [actionError, setActionError] = useState3(null);
1373
+ const run = useCallback3(async (task) => {
1374
+ setBusy(true);
1375
+ setActionError(null);
1376
+ try {
1377
+ await task();
1378
+ return true;
1379
+ } catch (cause) {
1380
+ setActionError(relayError(cause));
1381
+ return false;
1382
+ } finally {
1383
+ setBusy(false);
1384
+ }
1385
+ }, []);
1386
+ return { busy, actionError, run };
1387
+ }
1388
+ function useScopeSession() {
1389
+ const store = useStore();
1390
+ return useScope(store)?.session?.id;
1391
+ }
1392
+
1393
+ // src/react/rewards.ts
1394
+ function useEntitlements(options = {}) {
1395
+ const relay = useRelay();
1396
+ return useMemberQuery(
1397
+ [...relayKeys.rewards, "entitlements"],
1398
+ (context) => relay.rewards.entitlements(context),
1399
+ { intervalMs: 5e3, ...options }
1400
+ );
1401
+ }
1402
+ function useDeliveries(options = {}) {
1403
+ const relay = useRelay();
1404
+ return useMemberQuery(
1405
+ [...relayKeys.rewards, "deliveries"],
1406
+ (context) => relay.rewards.deliveries(context),
1407
+ { intervalMs: 5e3, ...options }
394
1408
  );
395
- return /* @__PURE__ */ jsx2(ClientContext, { value, children: /* @__PURE__ */ jsx2(ExperienceProvider, { children }) });
396
1409
  }
397
1410
 
398
1411
  // src/react/quest-groups.ts
@@ -416,10 +1429,17 @@ function questGroups(experience, order = []) {
416
1429
  }
417
1430
 
418
1431
  // src/react/use-quest.ts
419
- import { useCallback, useEffect as useEffect3, useState as useState4 } from "react";
1432
+ import {
1433
+ useCallback as useCallback4,
1434
+ useEffect as useEffect5,
1435
+ useLayoutEffect as useLayoutEffect3,
1436
+ useMemo as useMemo4,
1437
+ useRef as useRef3,
1438
+ useState as useState4
1439
+ } from "react";
420
1440
  function useQuest(observed) {
421
- const relay = useRelay();
422
- const { refresh } = useExperience();
1441
+ const store = useStore();
1442
+ const relay = store.client;
423
1443
  const [latest, setLatest] = useState4(null);
424
1444
  const [draft, setDraft] = useState4(null);
425
1445
  const [notice, setNotice] = useState4(null);
@@ -428,12 +1448,12 @@ function useQuest(observed) {
428
1448
  const [discardedAttempt, setDiscardedAttempt] = useState4(null);
429
1449
  const [saved, setSaved] = useState4("none");
430
1450
  const [receipt, setReceipt] = useState4(null);
431
- useEffect3(() => setLatest(null), [observed.release]);
1451
+ useEffect5(() => setLatest(null), [observed.release]);
432
1452
  const view = latest ?? observed;
433
1453
  const { quest, release } = view;
434
1454
  const completed = isFinished(view) || isAutomatic(view);
435
1455
  const attempt = view.attempt?.id;
436
- useEffect3(() => {
1456
+ useEffect5(() => {
437
1457
  let active = true;
438
1458
  relay.quests.open(view).then((controller) => controller.status()).then((status) => {
439
1459
  if (!active) {
@@ -448,7 +1468,7 @@ function useQuest(observed) {
448
1468
  active = false;
449
1469
  };
450
1470
  }, [relay, quest, release, completed, attempt, busy]);
451
- const run = useCallback(
1471
+ const run = useCallback4(
452
1472
  async (action) => {
453
1473
  setBusy(true);
454
1474
  setError(null);
@@ -464,158 +1484,140 @@ function useQuest(observed) {
464
1484
  setReceipt(result.attempt.id);
465
1485
  setNotice("received");
466
1486
  }
467
- await refresh();
1487
+ await store.refreshExperience();
468
1488
  } catch (cause) {
469
- setError(
470
- cause instanceof Error ? cause.message : "Something went wrong."
471
- );
1489
+ setError(asRelayError(cause, "Something went wrong."));
472
1490
  } finally {
473
1491
  setBusy(false);
474
1492
  }
475
1493
  },
476
- [refresh]
1494
+ [store]
477
1495
  );
478
- return {
479
- /** The newest known version of the quest. Render this, not the prop. */
480
- view,
481
- /** Input recovered after the quest changed. Prefill the form with it. */
482
- draft,
483
- notice,
484
- error,
485
- busy,
486
- saved,
487
- /** The attempt the last "received" notice is about. */
488
- receipt,
489
- startingFresh: view.attempt?.id === discardedAttempt && view.attempt?.status === "failed",
490
- restart: async () => {
491
- setBusy(true);
492
- setError(null);
493
- try {
494
- await (await relay.quests.open(view)).discard();
495
- setDiscardedAttempt(view.attempt?.id ?? null);
496
- setSaved("none");
497
- setDraft(null);
498
- setNotice(null);
499
- } catch (cause) {
500
- setError(
501
- cause instanceof Error ? cause.message : "Could not start a new submission."
502
- );
503
- } finally {
504
- setBusy(false);
505
- }
506
- },
507
- submit: (input) => run(async () => {
508
- if (view.connections.length) {
509
- const accounts = await relay.auth.accounts();
510
- const missing = view.connections.find(
511
- (provider) => !accounts.some(
512
- (account) => account.provider === provider && account.configured && account.status === "connected"
513
- )
514
- );
515
- if (missing) {
516
- throw new Error(`Connect ${missing} before verifying.`);
1496
+ const shown = useRef3(view);
1497
+ useLayoutEffect3(() => {
1498
+ shown.current = view;
1499
+ });
1500
+ const actions = useMemo4(
1501
+ () => ({
1502
+ restart: async () => {
1503
+ const current = shown.current;
1504
+ setBusy(true);
1505
+ setError(null);
1506
+ try {
1507
+ await (await relay.quests.open(current)).discard();
1508
+ setDiscardedAttempt(current.attempt?.id ?? null);
1509
+ setSaved("none");
1510
+ setDraft(null);
1511
+ setNotice(null);
1512
+ } catch (cause) {
1513
+ setError(asRelayError(cause, "Could not start a new submission."));
1514
+ } finally {
1515
+ setBusy(false);
517
1516
  }
518
- }
519
- const controller = await relay.quests.open(view);
520
- if ((controller.kind === "text" || controller.kind === "url") && input.kind === controller.kind) {
521
- return controller.submit(input.value);
522
- }
523
- if (controller.kind === "claim" && input.kind === "claim") {
524
- return controller.submit();
525
- }
526
- if (controller.kind === "quiz" && input.kind === "quiz") {
527
- return controller.submit(input.answers);
528
- }
529
- if (controller.kind === "photo" && input.kind === "photo") {
530
- return controller.submit(input.file);
531
- }
532
- throw new Error("This activity changed. Reload and try again.");
1517
+ },
1518
+ submit: (input) => run(async () => {
1519
+ const current = shown.current;
1520
+ if (current.connections.length) {
1521
+ const accounts = await relay.auth.accounts();
1522
+ const missing = current.connections.find(
1523
+ (provider) => !accounts.some(
1524
+ (account) => account.provider === provider && account.configured && account.status === "connected"
1525
+ )
1526
+ );
1527
+ if (missing) {
1528
+ throw new Error(`Connect ${missing} before verifying.`);
1529
+ }
1530
+ }
1531
+ const controller = await relay.quests.open(current);
1532
+ if ((controller.kind === "text" || controller.kind === "url") && input.kind === controller.kind) {
1533
+ return controller.submit(input.value);
1534
+ }
1535
+ if (controller.kind === "claim" && input.kind === "claim") {
1536
+ return controller.submit();
1537
+ }
1538
+ if (controller.kind === "quiz" && input.kind === "quiz") {
1539
+ return controller.submit(input.answers);
1540
+ }
1541
+ if (controller.kind === "photo" && input.kind === "photo") {
1542
+ return controller.submit(input.file);
1543
+ }
1544
+ throw new Error("This activity changed. Reload and try again.");
1545
+ }),
1546
+ /** Finish an interrupted submission, or reopen an outdated draft. */
1547
+ resume: () => run(async () => (await relay.quests.open(shown.current)).resume()),
1548
+ /**
1549
+ * Re-run verification that failed to run. Never creates a new attempt.
1550
+ * Domino refuses this for automatic quests, so never offer it for them.
1551
+ */
1552
+ retry: () => run(async () => {
1553
+ const current = shown.current;
1554
+ if (isAutomatic(current)) {
1555
+ throw new Error("This activity updates by itself.");
1556
+ }
1557
+ return {
1558
+ kind: "submitted",
1559
+ attempt: await relay.quests.retry(current)
1560
+ };
1561
+ })
1562
+ }),
1563
+ [relay, run]
1564
+ );
1565
+ const startingFresh = view.attempt?.id === discardedAttempt && view.attempt?.status === "failed";
1566
+ return useMemo4(
1567
+ () => ({
1568
+ /** The newest known version of the quest. Render this, not the prop. */
1569
+ view,
1570
+ /** Input recovered after the quest changed. Prefill the form with it. */
1571
+ draft,
1572
+ notice,
1573
+ error,
1574
+ busy,
1575
+ saved,
1576
+ /** The attempt the last "received" notice is about. */
1577
+ receipt,
1578
+ startingFresh,
1579
+ ...actions
533
1580
  }),
534
- /** Finish an interrupted submission, or reopen an outdated draft. */
535
- resume: () => run(async () => (await relay.quests.open(view)).resume()),
536
- /**
537
- * Re-run verification that failed to run. Never creates a new attempt.
538
- * Domino refuses this for automatic quests, so never offer it for them.
539
- */
540
- retry: () => run(async () => {
541
- if (isAutomatic(view)) {
542
- throw new Error("This activity updates by itself.");
543
- }
544
- return {
545
- kind: "submitted",
546
- attempt: await relay.quests.retry(view)
547
- };
548
- })
549
- };
1581
+ [view, draft, notice, error, busy, saved, receipt, startingFresh, actions]
1582
+ );
550
1583
  }
551
1584
 
552
1585
  // src/react/auth.ts
553
- import { useCallback as useCallback3, useEffect as useEffect5, useMemo as useMemo2, useRef as useRef2, useState as useState6 } from "react";
1586
+ import { useCallback as useCallback5, useMemo as useMemo6, useRef as useRef4, useState as useState6 } from "react";
554
1587
 
555
1588
  // src/react/wallets.ts
556
- import { useCallback as useCallback2, useEffect as useEffect4, useState as useState5 } from "react";
557
- var WALLETS_CHANGED = "relay-wallets-changed";
1589
+ import { useEffect as useEffect6, useMemo as useMemo5, useState as useState5 } from "react";
558
1590
  function useWallets() {
559
1591
  const relay = useRelay();
560
- const { member } = useExperience();
561
- const [wallets, setWallets] = useState5(null);
562
- const [error, setError] = useState5(null);
563
- const [busy, setBusy] = useState5(false);
564
- const refresh = useCallback2(async () => {
565
- try {
566
- setWallets(await relay.wallets.list());
567
- setError(null);
568
- } catch (cause) {
569
- setError(
570
- cause instanceof Error && cause.message ? cause.message : "Could not load linked wallets."
571
- );
572
- }
573
- }, [relay]);
574
- useEffect4(() => {
575
- if (!member) {
576
- setWallets(null);
577
- return;
578
- }
579
- const update = () => void refresh();
580
- update();
581
- window.addEventListener("focus", update);
582
- window.addEventListener(WALLETS_CHANGED, update);
583
- return () => {
584
- window.removeEventListener("focus", update);
585
- window.removeEventListener(WALLETS_CHANGED, update);
1592
+ const invalidate = useInvalidate();
1593
+ const session = useScopeSession();
1594
+ const query = useMemberQuery(
1595
+ relayKeys.wallets,
1596
+ (options) => relay.wallets.list(options),
1597
+ { refetchOnFocus: true }
1598
+ );
1599
+ const { busy, actionError, run } = useAction();
1600
+ const actions = useMemo5(() => {
1601
+ const changed = () => void invalidate([relayKeys.wallets, relayKeys.experience]);
1602
+ return {
1603
+ /** Links the wallet to the member the list was read for; another session is refused. */
1604
+ link: (signer) => run(async () => {
1605
+ await relay.wallets.link(signer, { session });
1606
+ changed();
1607
+ }),
1608
+ unlink: (wallet) => run(async () => {
1609
+ await relay.wallets.unlink(
1610
+ { chain: wallet.chain, address: wallet.address },
1611
+ { session }
1612
+ );
1613
+ changed();
1614
+ })
586
1615
  };
587
- }, [member, refresh]);
588
- async function run(task, fallback) {
589
- setBusy(true);
590
- setError(null);
591
- try {
592
- await task();
593
- } catch (cause) {
594
- setError(
595
- cause instanceof Error && cause.message ? cause.message : fallback
596
- );
597
- } finally {
598
- setBusy(false);
599
- }
600
- }
601
- return {
602
- /** Null until read, and while nobody is signed in. */
603
- wallets,
604
- error,
605
- busy,
606
- refresh,
607
- link: (signer) => run(async () => {
608
- await relay.wallets.link(signer);
609
- window.dispatchEvent(new Event(WALLETS_CHANGED));
610
- }, "Couldn't link this wallet. Try again."),
611
- unlink: (wallet) => run(async () => {
612
- await relay.wallets.unlink({
613
- chain: wallet.chain,
614
- address: wallet.address
615
- });
616
- window.dispatchEvent(new Event(WALLETS_CHANGED));
617
- }, "Couldn't unlink this wallet. Try again.")
618
- };
1616
+ }, [relay, invalidate, session, run]);
1617
+ return useMemo5(
1618
+ () => ({ ...query, busy, actionError, ...actions }),
1619
+ [query, busy, actionError, actions]
1620
+ );
619
1621
  }
620
1622
  function discoverChains(key) {
621
1623
  return JSON.parse(key).flatMap(
@@ -625,7 +1627,7 @@ function discoverChains(key) {
625
1627
  function useWalletDiscovery(options = {}) {
626
1628
  const chains = JSON.stringify(cosmosChainList(options.cosmos));
627
1629
  const [found, setFound] = useState5({ evm: [], solana: [], cosmos: [] });
628
- useEffect4(() => {
1630
+ useEffect6(() => {
629
1631
  let active = true;
630
1632
  const solana = discoverSolanaWallets();
631
1633
  const cosmos = discoverChains(chains);
@@ -648,7 +1650,7 @@ function useSignInWallets(networks, options = {}) {
648
1650
  const key = networks ? `${networks.join(" ")}
649
1651
  ${chains}` : null;
650
1652
  const [found, setFound] = useState5(null);
651
- useEffect4(() => {
1653
+ useEffect6(() => {
652
1654
  if (key === null) {
653
1655
  return;
654
1656
  }
@@ -670,73 +1672,47 @@ ${chains}` : null;
670
1672
  }
671
1673
 
672
1674
  // src/react/auth.ts
1675
+ var NONE_UNAVAILABLE = [];
673
1676
  function useSignInMethods() {
674
1677
  const relay = useRelay();
675
- const [methods, setMethods] = useState6(null);
676
- useEffect5(() => {
677
- let active = true;
678
- void readSignInMethods(relay).then((result) => {
679
- if (active) {
680
- setMethods(result);
681
- }
682
- });
683
- return () => {
684
- active = false;
685
- };
686
- }, [relay]);
687
- return methods;
1678
+ return useRelayQuery(relayKeys.signInMethods, () => readSignInMethods(relay));
688
1679
  }
689
1680
  function useMe() {
690
1681
  const relay = useRelay();
691
- const { member } = useExperience();
692
- const [me, setMe] = useState6(null);
693
- useEffect5(() => {
694
- if (!member) {
695
- setMe(null);
696
- return;
697
- }
698
- let active = true;
699
- void relay.me.get().then((value) => active && setMe(value)).catch(() => active && setMe(null));
700
- return () => {
701
- active = false;
702
- };
703
- }, [relay, member]);
704
- return me;
1682
+ return useMemberQuery(relayKeys.me, (options) => relay.me.get(options));
705
1683
  }
706
1684
  function useSignIn() {
707
1685
  const relay = useRelay();
708
1686
  const [challenge, setChallengeState] = useState6(null);
709
- const latest = useRef2(null);
1687
+ const latest = useRef4(null);
710
1688
  const [busy, setBusy] = useState6(false);
711
1689
  const [error, setError] = useState6(null);
712
- const setChallenge = useCallback3((value) => {
1690
+ const setChallenge = useCallback5((value) => {
713
1691
  latest.current = value;
714
1692
  setChallengeState(value);
715
1693
  }, []);
716
- const run = useCallback3(
1694
+ const run = useCallback5(
717
1695
  async (task, fallback) => {
718
1696
  setBusy(true);
719
1697
  setError(null);
720
1698
  try {
721
1699
  await task();
722
1700
  } catch (cause) {
723
- setError(
724
- cause instanceof Error && cause.message ? cause.message : fallback
725
- );
1701
+ setError(asRelayError(cause, fallback));
726
1702
  } finally {
727
1703
  setBusy(false);
728
1704
  }
729
1705
  },
730
1706
  []
731
1707
  );
732
- const sendCode = useCallback3(
1708
+ const sendCode = useCallback5(
733
1709
  (email) => run(async () => {
734
1710
  setChallenge(null);
735
1711
  setChallenge((await relay.auth.emailStart(email)).challenge);
736
1712
  }, "Couldn't send a code. Check the address and try again."),
737
1713
  [relay, run, setChallenge]
738
1714
  );
739
- const verifyCode = useCallback3(
1715
+ const verifyCode = useCallback5(
740
1716
  (code) => run(async () => {
741
1717
  const sent = latest.current;
742
1718
  if (!sent) {
@@ -747,59 +1723,70 @@ function useSignIn() {
747
1723
  }, "That code didn't work. Check it, or request a new one."),
748
1724
  [relay, run, setChallenge]
749
1725
  );
750
- const startOver = useCallback3(() => {
1726
+ const startOver = useCallback5(() => {
751
1727
  setChallenge(null);
752
1728
  setError(null);
753
1729
  }, [setChallenge]);
754
- const withProvider = useCallback3(
1730
+ const withProvider = useCallback5(
755
1731
  (provider) => run(async () => {
756
1732
  const { url } = await relay.auth.start(provider, "sign-in");
757
1733
  window.location.assign(url);
758
1734
  }, "Couldn't start sign-in. Try again."),
759
1735
  [relay, run]
760
1736
  );
761
- const withWallet = useCallback3(
1737
+ const withWallet = useCallback5(
762
1738
  (signer) => run(async () => {
763
1739
  await relay.auth.wallet.signIn(signer);
764
1740
  setChallenge(null);
765
1741
  }, "Couldn't sign in with this wallet. Try again."),
766
1742
  [relay, run, setChallenge]
767
1743
  );
768
- return {
769
- busy,
770
- error,
771
- /** True once a code was sent and the form should ask for it. */
772
- codeSent: challenge !== null,
773
- sendCode,
774
- verifyCode,
775
- startOver,
776
- withProvider,
777
- /** Signs in, or signs up, with a wallet; the wallet asks the participant to sign a message. */
778
- withWallet
779
- };
1744
+ const codeSent = challenge !== null;
1745
+ return useMemo6(
1746
+ () => ({
1747
+ busy,
1748
+ error,
1749
+ /** True once a code was sent and the form should ask for it. */
1750
+ codeSent,
1751
+ sendCode,
1752
+ verifyCode,
1753
+ startOver,
1754
+ withProvider,
1755
+ /** Signs in, or signs up, with a wallet; the wallet asks the participant to sign a message. */
1756
+ withWallet
1757
+ }),
1758
+ [
1759
+ busy,
1760
+ error,
1761
+ codeSent,
1762
+ sendCode,
1763
+ verifyCode,
1764
+ startOver,
1765
+ withProvider,
1766
+ withWallet
1767
+ ]
1768
+ );
780
1769
  }
781
1770
  function useSignOut() {
782
- const relay = useRelay();
1771
+ const store = useStore();
783
1772
  const [busy, setBusy] = useState6(false);
784
- return {
785
- busy,
786
- signOut: async () => {
787
- setBusy(true);
788
- try {
789
- await relay.auth.signOut();
790
- } finally {
791
- setBusy(false);
792
- }
1773
+ const signOut = useCallback5(async () => {
1774
+ setBusy(true);
1775
+ try {
1776
+ await store.signOut();
1777
+ } finally {
1778
+ setBusy(false);
793
1779
  }
794
- };
1780
+ }, [store]);
1781
+ return useMemo6(() => ({ busy, signOut }), [busy, signOut]);
795
1782
  }
796
1783
  function useSignInOptions(input = {}) {
797
1784
  const chains = JSON.stringify(cosmosChainList(input.cosmos));
798
- const cosmos = useMemo2(
1785
+ const cosmos = useMemo6(
799
1786
  () => JSON.parse(chains),
800
1787
  [chains]
801
1788
  );
802
- const methods = useSignInMethods();
1789
+ const methods = useSignInMethods().data ?? null;
803
1790
  const wallets = useSignInWallets(methods?.wallets, { cosmos });
804
1791
  const {
805
1792
  busy,
@@ -811,7 +1798,7 @@ function useSignInOptions(input = {}) {
811
1798
  withProvider,
812
1799
  withWallet
813
1800
  } = useSignIn();
814
- const built = useMemo2(() => {
1801
+ const built = useMemo6(() => {
815
1802
  if (!methods) {
816
1803
  return null;
817
1804
  }
@@ -829,20 +1816,23 @@ function useSignInOptions(input = {}) {
829
1816
  }
830
1817
  });
831
1818
  }, [methods, wallets, cosmos, withProvider, sendCode, verifyCode, withWallet]);
832
- return {
833
- options: built?.options ?? null,
834
- unavailable: built?.unavailable ?? [],
835
- methodsError: methods?.error ?? null,
836
- busy,
837
- error,
838
- codeSent,
839
- startOver
840
- };
1819
+ const methodsError = methods?.error ?? null;
1820
+ return useMemo6(
1821
+ () => ({
1822
+ options: built?.options ?? null,
1823
+ unavailable: built?.unavailable ?? NONE_UNAVAILABLE,
1824
+ methodsError,
1825
+ busy,
1826
+ error,
1827
+ codeSent,
1828
+ startOver
1829
+ }),
1830
+ [built, methodsError, busy, error, codeSent, startOver]
1831
+ );
841
1832
  }
842
1833
 
843
1834
  // src/react/accounts.ts
844
- import { useCallback as useCallback4, useEffect as useEffect6, useState as useState7 } from "react";
845
- var CHANGED = "relay-accounts-changed";
1835
+ import { useMemo as useMemo7 } from "react";
846
1836
  function isConnected(accounts, provider) {
847
1837
  return Boolean(
848
1838
  accounts?.some(
@@ -852,304 +1842,124 @@ function isConnected(accounts, provider) {
852
1842
  }
853
1843
  function useAccounts() {
854
1844
  const relay = useRelay();
855
- const { member } = useExperience();
856
- const [accounts, setAccounts] = useState7(null);
857
- const [error, setError] = useState7(null);
858
- const [busy, setBusy] = useState7(false);
859
- const refresh = useCallback4(async () => {
860
- try {
861
- setAccounts(await relay.auth.accounts());
862
- setError(null);
863
- } catch (cause) {
864
- setError(
865
- cause instanceof Error && cause.message ? cause.message : "Could not load connected accounts."
866
- );
867
- }
868
- }, [relay]);
869
- useEffect6(() => {
870
- if (!member) {
871
- setAccounts(null);
872
- return;
873
- }
874
- const update = () => void refresh();
875
- update();
876
- window.addEventListener("focus", update);
877
- window.addEventListener(CHANGED, update);
878
- return () => {
879
- window.removeEventListener("focus", update);
880
- window.removeEventListener(CHANGED, update);
881
- };
882
- }, [member, refresh]);
883
- async function run(task, fallback) {
884
- setBusy(true);
885
- setError(null);
886
- try {
887
- await task();
888
- } catch (cause) {
889
- setError(
890
- cause instanceof Error && cause.message ? cause.message : fallback
891
- );
892
- } finally {
893
- setBusy(false);
894
- }
895
- }
896
- return {
897
- /** Null until read, and while nobody is signed in. */
898
- accounts,
899
- error,
900
- busy,
901
- refresh,
902
- isConnected: (provider) => isConnected(accounts, provider),
903
- connect: (provider) => run(async () => {
904
- const { url } = await relay.auth.start(provider, "connect");
905
- window.location.assign(url);
906
- }, "Couldn't start connecting. Try again."),
907
- disconnect: (provider) => run(async () => {
908
- await relay.auth.disconnect(provider);
909
- window.dispatchEvent(new Event(CHANGED));
910
- }, "Couldn't disconnect. Try again.")
911
- };
1845
+ const invalidate = useInvalidate();
1846
+ const session = useScopeSession();
1847
+ const query = useMemberQuery(
1848
+ relayKeys.accounts,
1849
+ (options) => relay.auth.accounts(options),
1850
+ { refetchOnFocus: true }
1851
+ );
1852
+ const { busy, actionError, run } = useAction();
1853
+ const actions = useMemo7(
1854
+ () => ({
1855
+ connect: (provider) => run(async () => {
1856
+ const { url } = await relay.auth.start(provider, "connect", {
1857
+ session
1858
+ });
1859
+ window.location.assign(url);
1860
+ }),
1861
+ disconnect: (provider) => run(async () => {
1862
+ await relay.auth.disconnect(provider, { session });
1863
+ void invalidate([
1864
+ relayKeys.accounts,
1865
+ relayKeys.experience,
1866
+ relayKeys.profiles
1867
+ ]);
1868
+ })
1869
+ }),
1870
+ [relay, invalidate, session, run]
1871
+ );
1872
+ const accounts = query.data;
1873
+ return useMemo7(
1874
+ () => ({
1875
+ ...query,
1876
+ busy,
1877
+ actionError,
1878
+ isConnected: (provider) => isConnected(accounts, provider),
1879
+ ...actions
1880
+ }),
1881
+ [query, busy, actionError, accounts, actions]
1882
+ );
912
1883
  }
913
1884
 
914
1885
  // src/react/profiles.ts
915
- import { useCallback as useCallback5, useEffect as useEffect7, useRef as useRef3, useState as useState8 } from "react";
916
- var PROFILE_CHANGED = "relay-profile-changed";
1886
+ import { useMemo as useMemo8 } from "react";
1887
+ var MINE = [...relayKeys.profiles, "mine"];
917
1888
  function useMyProfile() {
918
1889
  const relay = useRelay();
919
- const { member } = useExperience();
920
- const [profile, setProfile] = useState8(null);
921
- const [error, setError] = useState8(null);
922
- const [busy, setBusy] = useState8(false);
923
- const session = useRef3(null);
924
- const refresh = useCallback5(async () => {
925
- try {
926
- const [current, value] = await Promise.all([
927
- relay.auth.session(),
928
- relay.profiles.mine()
1890
+ const store = useStore();
1891
+ const session = useScopeSession();
1892
+ const query = useMemberQuery(
1893
+ MINE,
1894
+ (options) => relay.profiles.mine(options),
1895
+ { refetchOnFocus: true }
1896
+ );
1897
+ const { busy, actionError, run } = useAction();
1898
+ const actions = useMemo8(() => {
1899
+ const change = (task) => run(async () => {
1900
+ const cache = store.scope?.cache;
1901
+ const saved = await task();
1902
+ cache?.set(MINE, saved);
1903
+ void cache?.invalidate([
1904
+ [...relayKeys.profiles, "get"],
1905
+ relayKeys.leaderboards
929
1906
  ]);
930
- session.current = current?.id ?? null;
931
- setProfile(value);
932
- setError(null);
933
- } catch (cause) {
934
- setError(
935
- cause instanceof Error && cause.message ? cause.message : "Could not load your profile."
936
- );
937
- }
938
- }, [relay]);
939
- useEffect7(() => {
940
- if (!member) {
941
- setProfile(null);
942
- session.current = null;
943
- return;
944
- }
945
- const update = () => void refresh();
946
- update();
947
- window.addEventListener("focus", update);
948
- window.addEventListener(PROFILE_CHANGED, update);
949
- return () => {
950
- window.removeEventListener("focus", update);
951
- window.removeEventListener(PROFILE_CHANGED, update);
952
- };
953
- }, [member, refresh]);
954
- async function run(task, fallback) {
955
- setBusy(true);
956
- setError(null);
957
- try {
958
- const value = await task(session.current ?? void 0);
959
- setProfile(value);
960
- window.dispatchEvent(new Event(PROFILE_CHANGED));
961
- return true;
962
- } catch (cause) {
963
- setError(
964
- cause instanceof Error && cause.message ? cause.message : fallback
965
- );
966
- return false;
967
- } finally {
968
- setBusy(false);
969
- }
970
- }
971
- return {
972
- /** Null until read, and while nobody is signed in. */
973
- profile,
974
- error,
975
- busy,
976
- refresh,
977
- /** Resolves true once saved; a failure sets `error`. */
978
- update: (input) => run(
979
- (value) => relay.profiles.update(input, { session: value }),
980
- "Couldn't save your profile. Try again."
981
- ),
982
- /** Uploads a JPEG, PNG or WebP of at most 5 MB as the avatar; resolves true once saved. */
983
- uploadAvatar: (file) => run(
984
- (value) => relay.profiles.uploadAvatar(file, {
985
- uploadId: crypto.randomUUID(),
986
- session: value
987
- }),
988
- "Couldn't upload this picture. Try again."
989
- )
990
- };
991
- }
992
- function useProfileRead(read, intervalMs, fallback) {
993
- const { member } = useExperience();
994
- const [state, setState] = useState8({
995
- status: "loading"
996
- });
997
- const refresh = useCallback5(async () => {
998
- try {
999
- const value = await read();
1000
- setState({ status: "ready", value, stale: null });
1001
- } catch (cause) {
1002
- if (cause instanceof RelayError && cause.status === 404) {
1003
- setState({ status: "missing" });
1004
- return;
1005
- }
1006
- const message = cause instanceof Error && cause.message ? cause.message : fallback;
1007
- setState(
1008
- (previous) => previous.status === "ready" ? { ...previous, stale: message } : { status: "error", message }
1009
- );
1010
- }
1011
- }, [read, fallback]);
1012
- useEffect7(() => {
1013
- setState({ status: "loading" });
1014
- void refresh();
1015
- const timer = setInterval(() => {
1016
- if (document.visibilityState === "visible") {
1017
- void refresh();
1018
- }
1019
- }, intervalMs);
1020
- const changed = () => void refresh();
1021
- window.addEventListener(PROFILE_CHANGED, changed);
1022
- return () => {
1023
- clearInterval(timer);
1024
- window.removeEventListener(PROFILE_CHANGED, changed);
1907
+ });
1908
+ return {
1909
+ update: (input) => change(() => relay.profiles.update(input, { session })),
1910
+ /** Uploads a JPEG, PNG or WebP of at most 5 MB as the avatar. */
1911
+ uploadAvatar: (file) => change(
1912
+ () => relay.profiles.uploadAvatar(file, {
1913
+ uploadId: crypto.randomUUID(),
1914
+ session
1915
+ })
1916
+ )
1025
1917
  };
1026
- }, [refresh, member, intervalMs]);
1027
- return { state, refresh };
1918
+ }, [relay, store, session, run]);
1919
+ return useMemo8(
1920
+ () => ({ ...query, busy, actionError, ...actions }),
1921
+ [query, busy, actionError, actions]
1922
+ );
1028
1923
  }
1029
1924
  function useProfile(id, options = {}) {
1030
1925
  const relay = useRelay();
1031
- const read = useCallback5(() => relay.profiles.get(id), [relay, id]);
1032
- const { state, refresh } = useProfileRead(
1033
- read,
1034
- options.intervalMs ?? 6e4,
1035
- "Could not load the profile."
1926
+ return useRelayQuery(
1927
+ [...relayKeys.profiles, "get", id],
1928
+ (request) => relay.profiles.get(id, request),
1929
+ { intervalMs: options.intervalMs ?? 6e4 }
1036
1930
  );
1037
- const value = state.status === "ready" ? { status: "ready", profile: state.value, stale: state.stale } : state;
1038
- return { ...value, refresh };
1039
1931
  }
1040
1932
  function useLeaderboardPlacement(board, profile, options = {}) {
1041
1933
  const relay = useRelay();
1042
- const read = useCallback5(
1043
- () => relay.leaderboards.placement(board, profile),
1044
- [relay, board, profile]
1934
+ return useRelayQuery(
1935
+ [...relayKeys.leaderboards, board, "placement", profile],
1936
+ (request) => relay.leaderboards.placement(board, profile, request),
1937
+ { intervalMs: options.intervalMs ?? 3e4 }
1045
1938
  );
1046
- const { state, refresh } = useProfileRead(
1047
- read,
1048
- options.intervalMs ?? 3e4,
1049
- "Could not load the leaderboard placement."
1050
- );
1051
- return { ...state, refresh };
1052
1939
  }
1053
1940
  function useProfileReferral(profile, program, options = {}) {
1054
1941
  const relay = useRelay();
1055
- const read = useCallback5(
1056
- () => relay.profiles.referral(profile, program),
1057
- [relay, profile, program]
1058
- );
1059
- const { state, refresh } = useProfileRead(
1060
- read,
1061
- options.intervalMs ?? 6e4,
1062
- "Could not load referral progress."
1942
+ return useMemberQuery(
1943
+ [...relayKeys.profiles, "referral", profile, program],
1944
+ (request) => relay.profiles.referral(profile, program, request),
1945
+ { intervalMs: options.intervalMs ?? 6e4 }
1063
1946
  );
1064
- return { ...state, refresh };
1065
1947
  }
1066
1948
  function useProfileCompletions(profile, options = {}) {
1067
1949
  const relay = useRelay();
1068
- const { member } = useExperience();
1069
1950
  const { limit = 20 } = options;
1070
- const [state, setState] = useState8({
1071
- status: "loading"
1072
- });
1073
- const cursor = useRef3(null);
1074
- const generation = useRef3(0);
1075
- const paging = useRef3({ ready: false, busy: false });
1076
- const load = useCallback5(
1077
- async (first) => {
1078
- if (first) {
1079
- generation.current++;
1080
- paging.current = { ready: false, busy: false };
1081
- } else {
1082
- if (!paging.current.ready || paging.current.busy || cursor.current === null) {
1083
- return;
1084
- }
1085
- paging.current.busy = true;
1086
- setState(
1087
- (previous) => previous.status === "ready" ? { ...previous, loading: true, error: null } : previous
1088
- );
1089
- }
1090
- const current = generation.current;
1091
- const own = paging.current;
1092
- try {
1093
- const page = await relay.profiles.completions(profile, {
1094
- limit,
1095
- ...!first && cursor.current ? { cursor: cursor.current } : {}
1096
- });
1097
- if (current !== generation.current) {
1098
- return;
1099
- }
1100
- cursor.current = page.nextCursor;
1101
- own.ready = true;
1102
- setState((previous) => ({
1103
- status: "ready",
1104
- items: !first && previous.status === "ready" ? [...previous.items, ...page.items] : page.items,
1105
- more: page.nextCursor !== null,
1106
- loading: false,
1107
- error: null
1108
- }));
1109
- } catch (cause) {
1110
- if (current !== generation.current) {
1111
- return;
1112
- }
1113
- if (first && cause instanceof RelayError && cause.status === 404) {
1114
- setState({ status: "missing" });
1115
- return;
1116
- }
1117
- const message = cause instanceof Error && cause.message ? cause.message : "Could not load completed quests.";
1118
- setState(
1119
- (previous) => !first && previous.status === "ready" ? { ...previous, loading: false, error: message } : { status: "error", message }
1120
- );
1121
- } finally {
1122
- if (!first) {
1123
- own.busy = false;
1124
- }
1125
- }
1126
- },
1127
- [relay, profile, limit]
1951
+ return useRelayPages(
1952
+ [...relayKeys.profiles, "completions", profile, limit],
1953
+ ({ cursor, ...request }) => relay.profiles.completions(
1954
+ profile,
1955
+ cursor ? { limit, cursor } : { limit },
1956
+ request
1957
+ )
1128
1958
  );
1129
- useEffect7(() => {
1130
- setState({ status: "loading" });
1131
- cursor.current = null;
1132
- void load(true);
1133
- const changed = () => void load(true);
1134
- window.addEventListener(PROFILE_CHANGED, changed);
1135
- return () => {
1136
- generation.current++;
1137
- window.removeEventListener(PROFILE_CHANGED, changed);
1138
- };
1139
- }, [load, member]);
1140
- return {
1141
- ...state,
1142
- refresh: () => load(true),
1143
- /**
1144
- * Appends the next page; does nothing while one loads, while the first page reloads, or when
1145
- * there is none.
1146
- */
1147
- loadMore: () => load(false)
1148
- };
1149
1959
  }
1150
1960
 
1151
1961
  // src/react/referrals.ts
1152
- import { useCallback as useCallback6, useEffect as useEffect8, useRef as useRef4, useState as useState9 } from "react";
1962
+ import { useCallback as useCallback6, useEffect as useEffect7, useMemo as useMemo9, useRef as useRef5, useState as useState7 } from "react";
1153
1963
  var PENDING = "domino:pending-referral";
1154
1964
  var CODE = /^[a-zA-Z0-9_-]{1,100}$/;
1155
1965
  function captureReferralCode(param = "ref") {
@@ -1174,12 +1984,13 @@ function forgetReferralCode() {
1174
1984
  }
1175
1985
  function useInvitation(param = "ref") {
1176
1986
  const relay = useRelay();
1177
- const { member } = useExperience();
1178
- const [state, setState] = useState9(() => {
1987
+ const invalidate = useInvalidate();
1988
+ const { member } = useSession();
1989
+ const [state, setState] = useState7(() => {
1179
1990
  const code = captureReferralCode(param);
1180
1991
  return code ? { status: "pending", code } : { status: "none" };
1181
1992
  });
1182
- const started = useRef4(null);
1993
+ const started = useRef5(null);
1183
1994
  const accept = useCallback6(
1184
1995
  async (code) => {
1185
1996
  setState({ status: "accepting", code });
@@ -1187,150 +1998,189 @@ function useInvitation(param = "ref") {
1187
1998
  await relay.referrals.accept({ code, actionId: `accept-${code}` });
1188
1999
  forgetReferralCode();
1189
2000
  setState({ status: "accepted", code });
2001
+ void invalidate([relayKeys.referrals, relayKeys.profiles]);
1190
2002
  } catch (cause) {
1191
- const refused = cause instanceof RelayError && (cause.kind === "http" && cause.status !== void 0 && cause.status >= 400 && cause.status < 500 || cause.code === RelayErrorCode.InvalidInput);
2003
+ const refused = isRelayError(cause) && (cause.kind === "http" && cause.status !== void 0 && cause.status >= 400 && cause.status < 500 && cause.status !== 429 || cause.code === RelayErrorCode.InvalidInput);
1192
2004
  if (refused) {
1193
2005
  forgetReferralCode();
1194
2006
  }
1195
2007
  setState({
1196
2008
  status: refused ? "rejected" : "failed",
1197
2009
  code,
1198
- reason: cause instanceof Error && cause.message ? cause.message : "That invitation code couldn't be used."
2010
+ error: asRelayError(cause, "That invitation code couldn't be used.")
1199
2011
  });
1200
2012
  }
1201
2013
  },
1202
- [relay]
2014
+ [relay, invalidate]
1203
2015
  );
1204
- useEffect8(() => {
2016
+ useEffect7(() => {
1205
2017
  if (member && state.status === "pending" && started.current !== `${member}:${state.code}`) {
1206
2018
  started.current = `${member}:${state.code}`;
1207
2019
  void accept(state.code);
1208
2020
  }
1209
2021
  }, [member, state, accept]);
1210
- return { invitation: state, accept };
2022
+ return useMemo9(() => ({ invitation: state, accept }), [state, accept]);
1211
2023
  }
1212
2024
  function useReferralSummary(options = {}) {
1213
2025
  const relay = useRelay();
1214
- const { member } = useExperience();
1215
- const [summary, setSummary] = useState9(null);
1216
- const [error, setError] = useState9(null);
1217
- const refresh = useCallback6(async () => {
1218
- try {
1219
- setSummary(await relay.referrals.summary());
1220
- setError(null);
1221
- } catch (cause) {
1222
- setError(
1223
- cause instanceof Error && cause.message ? cause.message : "Could not load your invitations."
1224
- );
1225
- }
1226
- }, [relay]);
1227
- useEffect8(() => {
1228
- setSummary(null);
1229
- if (member) {
1230
- void refresh();
2026
+ const { path = "/", param = "ref", intervalMs = 6e4 } = options;
2027
+ const query = useMemberQuery(
2028
+ [...relayKeys.referrals, "summary"],
2029
+ (request) => relay.referrals.summary(request),
2030
+ { intervalMs }
2031
+ );
2032
+ return useMemo9(() => {
2033
+ let link = null;
2034
+ if (query.data && typeof window !== "undefined") {
2035
+ const url = new URL(path, window.location.origin);
2036
+ url.searchParams.set(param, query.data.code);
2037
+ link = url.toString();
1231
2038
  }
1232
- }, [member, refresh]);
1233
- let link = null;
1234
- if (summary && typeof window !== "undefined") {
1235
- const url = new URL(options.path ?? "/", window.location.origin);
1236
- url.searchParams.set(options.param ?? "ref", summary.code);
1237
- link = url.toString();
1238
- }
1239
- return { summary, link, error, refresh };
2039
+ return { ...query, link };
2040
+ }, [query, path, param]);
1240
2041
  }
1241
2042
 
1242
2043
  // src/react/leaderboards.ts
1243
- import { useCallback as useCallback7, useEffect as useEffect9, useState as useState10 } from "react";
1244
2044
  function useLeaderboard(board, options = {}) {
1245
2045
  const relay = useRelay();
1246
- const { member } = useExperience();
1247
2046
  const { limit = 25, intervalMs = 3e4 } = options;
1248
- const [state, setState] = useState10({ status: "loading" });
1249
- const refresh = useCallback7(async () => {
1250
- try {
1251
- const page = await relay.leaderboards.standings(board, { limit });
1252
- setState({ status: "ready", page, stale: null });
1253
- } catch (cause) {
1254
- const message = cause instanceof Error && cause.message ? cause.message : "Could not load the leaderboard.";
1255
- setState(
1256
- (previous) => previous.status === "ready" ? { ...previous, stale: message } : { status: "error", message }
1257
- );
1258
- }
1259
- }, [relay, board, limit]);
1260
- useEffect9(() => {
1261
- void refresh();
1262
- const timer = setInterval(() => {
1263
- if (document.visibilityState === "visible") {
1264
- void refresh();
2047
+ return useRelayQuery(
2048
+ [...relayKeys.leaderboards, board, "standings", limit],
2049
+ (request) => relay.leaderboards.standings(board, { limit }, request),
2050
+ { intervalMs }
2051
+ );
2052
+ }
2053
+
2054
+ // src/react/balances.ts
2055
+ function useBalances() {
2056
+ const relay = useRelay();
2057
+ return useMemberQuery(
2058
+ relayKeys.balances,
2059
+ (options) => relay.me.balances(options)
2060
+ );
2061
+ }
2062
+ function usePointHistory(ledger) {
2063
+ const relay = useRelay();
2064
+ return useMemberPages(
2065
+ [...relayKeys.balances, "history", ledger],
2066
+ ({ cursor, ...options }) => relay.me.pointHistory(cursor ? { ledger, cursor } : { ledger }, options)
2067
+ );
2068
+ }
2069
+
2070
+ // src/react/outcomes.ts
2071
+ function useQuestOutcomes(query = {}) {
2072
+ const relay = useRelay();
2073
+ const { quest, state, code } = query;
2074
+ return useMemberPages(
2075
+ [...relayKeys.outcomes, quest ?? "", state ?? "", code ?? ""],
2076
+ ({ cursor, ...options }) => relay.quests.outcomes(
2077
+ {
2078
+ ...quest ? { quest } : {},
2079
+ ...state ? { state } : {},
2080
+ ...code ? { code } : {},
2081
+ ...cursor ? { cursor } : {}
2082
+ },
2083
+ options
2084
+ )
2085
+ );
2086
+ }
2087
+
2088
+ // src/react/offers.ts
2089
+ import { useCallback as useCallback7, useState as useState8 } from "react";
2090
+ function useOffers() {
2091
+ const relay = useRelay();
2092
+ return useRelayQuery(
2093
+ relayKeys.offers,
2094
+ (options) => relay.offers.list(options)
2095
+ );
2096
+ }
2097
+ function useRedeemOffer() {
2098
+ const relay = useRelay();
2099
+ const invalidate = useInvalidate();
2100
+ const [busy, setBusy] = useState8(false);
2101
+ const [error, setError] = useState8(null);
2102
+ const redeem = useCallback7(
2103
+ async (offer) => {
2104
+ if (busy) {
2105
+ return null;
2106
+ }
2107
+ setBusy(true);
2108
+ setError(null);
2109
+ try {
2110
+ const redemption = await relay.offers.redeem(offer);
2111
+ await invalidate([relayKeys.offers, relayKeys.balances]);
2112
+ return redemption;
2113
+ } catch (cause) {
2114
+ setError(asRelayError(cause, "This offer could not be redeemed."));
2115
+ return null;
2116
+ } finally {
2117
+ setBusy(false);
1265
2118
  }
1266
- }, intervalMs);
1267
- return () => clearInterval(timer);
1268
- }, [refresh, member, intervalMs]);
1269
- return { ...state, refresh };
2119
+ },
2120
+ [busy, invalidate, relay]
2121
+ );
2122
+ return { redeem, busy, error };
1270
2123
  }
1271
2124
 
1272
2125
  // src/react/references.ts
1273
- import { useCallback as useCallback8, useEffect as useEffect10, useState as useState11 } from "react";
1274
- function useReferences(read, failure) {
1275
- const relay = useRelay();
1276
- const [state, setState] = useState11({ status: "loading" });
1277
- const refresh = useCallback8(async () => {
1278
- try {
1279
- setState({ status: "ready", items: await read(relay) });
1280
- } catch (cause) {
1281
- setState({
1282
- status: "error",
1283
- message: cause instanceof Error && cause.message ? cause.message : failure
1284
- });
1285
- }
1286
- }, [relay, read, failure]);
1287
- useEffect10(() => {
1288
- void refresh();
1289
- }, [refresh]);
1290
- return { ...state, refresh };
1291
- }
1292
- var readPointLedgers = (relay) => relay.pointLedgers.list();
1293
- var readTieredRewards = (relay) => relay.tieredRewards.list();
1294
2126
  function usePointLedgers() {
1295
- return useReferences(readPointLedgers, "Could not load point ledgers.");
2127
+ const relay = useRelay();
2128
+ return useRelayQuery(
2129
+ relayKeys.pointLedgers,
2130
+ (options) => relay.pointLedgers.list(options)
2131
+ );
1296
2132
  }
1297
2133
  function useTieredRewards() {
1298
- return useReferences(readTieredRewards, "Could not load tiered rewards.");
2134
+ const relay = useRelay();
2135
+ return useRelayQuery(
2136
+ relayKeys.tieredRewards,
2137
+ (options) => relay.tieredRewards.list(options)
2138
+ );
1299
2139
  }
1300
2140
  export {
1301
- PENDING_POLL_MS,
1302
- PROFILE_CHANGED,
1303
- RETRY_DELAYS_MS,
1304
2141
  RelayProvider,
1305
2142
  canRepeat,
1306
2143
  canSubmit,
1307
2144
  captureReferralCode,
1308
- completionCountLabel,
1309
- completionMark,
1310
- hasPending,
2145
+ completionCountText,
1311
2146
  isAutomatic,
1312
2147
  isConnected,
1313
2148
  isFinished,
1314
2149
  progressOf,
1315
2150
  questGroups,
1316
2151
  questStatus,
2152
+ questStatusText,
2153
+ relayKeys,
1317
2154
  roleDeliveryLabel,
1318
- runsSilently,
1319
- sanitizeDeliveries,
2155
+ roleDeliveryStatus,
1320
2156
  useAccounts,
2157
+ useActivity,
2158
+ useBalances,
2159
+ useDeliveries,
2160
+ useEntitlements,
1321
2161
  useExperience,
2162
+ useInvalidate,
1322
2163
  useInvitation,
1323
2164
  useLeaderboard,
1324
2165
  useLeaderboardPlacement,
1325
2166
  useMe,
2167
+ useMemberPages,
2168
+ useMemberQuery,
1326
2169
  useMyProfile,
2170
+ useOffers,
2171
+ usePointHistory,
1327
2172
  usePointLedgers,
1328
2173
  useProfile,
1329
2174
  useProfileCompletions,
1330
2175
  useProfileReferral,
1331
2176
  useQuest,
2177
+ useQuestOutcomes,
2178
+ useRedeemOffer,
1332
2179
  useReferralSummary,
1333
2180
  useRelay,
2181
+ useRelayPages,
2182
+ useRelayQuery,
2183
+ useSession,
1334
2184
  useSignIn,
1335
2185
  useSignInMethods,
1336
2186
  useSignInOptions,
@@ -1338,7 +2188,5 @@ export {
1338
2188
  useSignOut,
1339
2189
  useTieredRewards,
1340
2190
  useWalletDiscovery,
1341
- useWallets,
1342
- visibleQuests,
1343
- watchRoleDeliveries
2191
+ useWallets
1344
2192
  };