@domino-sdk/relay 1.0.0-next.13 → 1.0.0-next.14

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.
@@ -8,40 +8,43 @@ import {
8
8
  discoverSolanaWallets,
9
9
  readSignInMethods,
10
10
  signInOptionsFrom
11
- } from "../chunk-WQ24ILGO.js";
12
- import "../chunk-EZWNGI5B.js";
13
- import "../chunk-M64XHC42.js";
11
+ } from "../chunk-FWRP6YAQ.js";
12
+ import "../chunk-F5OCJE52.js";
13
+ import "../chunk-K4ANDR6G.js";
14
14
  import {
15
- RelayError,
16
- RelayErrorCode
17
- } from "../chunk-OA2KMQVZ.js";
15
+ RelayErrorCode,
16
+ asRelayError,
17
+ isRelayError
18
+ } from "../chunk-TLGCEVG5.js";
18
19
  import {
19
20
  sessionMember
20
21
  } from "../chunk-FXYJ4OMX.js";
21
22
  import "../chunk-CRNQP7NV.js";
22
23
 
23
24
  // src/react/provider.tsx
24
- import { useState as useState3 } from "react";
25
+ import { useEffect as useEffect2, useMemo as useMemo2 } from "react";
25
26
 
26
27
  // src/react/context.ts
27
28
  import { createContext, useContext } from "react";
28
- var ClientContext = createContext(null);
29
- function useRelay() {
30
- const client = useContext(ClientContext);
31
- if (!client) {
29
+ var StoreContext = createContext(null);
30
+ function useStore() {
31
+ const store = useContext(StoreContext);
32
+ if (!store) {
32
33
  throw new Error("Domino hooks require <RelayProvider>");
33
34
  }
34
- return client;
35
+ return store;
36
+ }
37
+ function useRelay() {
38
+ return useStore().client;
35
39
  }
36
40
 
37
41
  // src/react/experience.tsx
38
42
  import {
39
43
  createContext as createContext2,
44
+ useCallback,
40
45
  useContext as useContext2,
41
- useEffect as useEffect2,
42
46
  useMemo,
43
- useRef,
44
- useState as useState2
47
+ useSyncExternalStore
45
48
  } from "react";
46
49
 
47
50
  // src/react/role-delivery.ts
@@ -54,19 +57,32 @@ function completionMark(quests) {
54
57
  function hasPending(deliveries) {
55
58
  return deliveries.some((d) => d.status === "pending");
56
59
  }
57
- function roleDeliveryLabel(deliveries, quest) {
60
+ function roleDeliveryStatus(deliveries, quest) {
58
61
  const mine = deliveries.filter((d) => d.quest === quest);
59
62
  if (!mine.length) {
60
63
  return null;
61
64
  }
62
65
  if (hasPending(mine)) {
63
- return "Role delivery pending";
66
+ return "pending";
67
+ }
68
+ return mine.some((d) => d.status === "failed") ? "failed" : "granted";
69
+ }
70
+ function roleDeliveryLabel(status) {
71
+ switch (status) {
72
+ case "pending":
73
+ return "Role delivery pending";
74
+ case "failed":
75
+ return "Role delivery failed";
76
+ case "granted":
77
+ return "Role granted";
78
+ default:
79
+ return null;
64
80
  }
65
- return mine.some((d) => d.status === "failed") ? "Role delivery failed" : "Role granted";
66
81
  }
67
82
  var PENDING_POLL_MS = 5e3;
68
83
  var RETRY_DELAYS_MS = [1e3, 4e3];
69
84
  function watchRoleDeliveries(options) {
85
+ const visible2 = options.visible ?? (() => typeof document === "undefined" || document.visibilityState !== "hidden");
70
86
  const schedule = options.schedule ?? ((run2, ms) => {
71
87
  const timer = setTimeout(run2, ms);
72
88
  return () => clearTimeout(timer);
@@ -82,6 +98,10 @@ function watchRoleDeliveries(options) {
82
98
  if (!active || reading) {
83
99
  return;
84
100
  }
101
+ if (!visible2()) {
102
+ resting = true;
103
+ return;
104
+ }
85
105
  reading = true;
86
106
  resting = false;
87
107
  try {
@@ -124,6 +144,355 @@ function watchRoleDeliveries(options) {
124
144
  };
125
145
  }
126
146
 
147
+ // src/react/use-role-deliveries.ts
148
+ import { useEffect, useState } from "react";
149
+ function useRoleDeliveries(relay, member, completions) {
150
+ const [state, setState] = useState(null);
151
+ useEffect(() => {
152
+ if (!member || !completions) {
153
+ return;
154
+ }
155
+ const watch = watchRoleDeliveries({
156
+ read: async () => (await relay.me.rewardDeliveries()).deliveries,
157
+ onChange: (deliveries) => setState({ member, deliveries })
158
+ });
159
+ const wake = () => {
160
+ if (document.visibilityState === "visible") {
161
+ watch.wake();
162
+ }
163
+ };
164
+ window.addEventListener("focus", wake);
165
+ document.addEventListener("visibilitychange", wake);
166
+ return () => {
167
+ watch.dispose();
168
+ window.removeEventListener("focus", wake);
169
+ document.removeEventListener("visibilitychange", wake);
170
+ };
171
+ }, [relay, member, completions]);
172
+ return member && completions && state?.member === member ? state.deliveries : [];
173
+ }
174
+
175
+ // src/react/experience.tsx
176
+ import { jsx } from "react/jsx-runtime";
177
+ var Deliveries = createContext2([]);
178
+ function useExperienceSnapshot() {
179
+ const store = useStore();
180
+ const get = () => store.experience;
181
+ return useSyncExternalStore(store.subscribe, get, get);
182
+ }
183
+ function DeliveriesProvider({ children }) {
184
+ const store = useStore();
185
+ const experience = useExperienceSnapshot();
186
+ const member = useSyncExternalStore(
187
+ store.subscribe,
188
+ () => store.scope?.member ?? null,
189
+ () => null
190
+ );
191
+ const ready = experience.status === "ready";
192
+ const deliveries = useRoleDeliveries(
193
+ store.client,
194
+ ready ? member : null,
195
+ ready ? completionMark([
196
+ ...experience.data.quests,
197
+ ...experience.data.collections.flatMap((c) => c.quests)
198
+ ]) : ""
199
+ );
200
+ return /* @__PURE__ */ jsx(Deliveries, { value: deliveries, children });
201
+ }
202
+ function useExperience() {
203
+ const store = useStore();
204
+ const experience = useExperienceSnapshot();
205
+ const deliveries = useContext2(Deliveries);
206
+ const refresh = useCallback(() => store.refreshExperience(), [store]);
207
+ return useMemo(
208
+ () => ({ ...experience, refresh, deliveries }),
209
+ [experience, refresh, deliveries]
210
+ );
211
+ }
212
+
213
+ // src/react/cache.ts
214
+ var LOADING = Object.freeze({
215
+ status: "loading",
216
+ data: void 0,
217
+ error: null
218
+ });
219
+ var UNUSED_MS = 5 * 6e4;
220
+ var STALE_MS = 3e4;
221
+ var relayError = (cause, fallback) => asRelayError(cause, fallback);
222
+ function hashKey(key) {
223
+ return JSON.stringify(key);
224
+ }
225
+ function startsWith(key, prefix) {
226
+ return prefix.every((part, index) => key[index] === part);
227
+ }
228
+ function visible() {
229
+ return typeof document === "undefined" || document.visibilityState !== "hidden";
230
+ }
231
+ var QueryCache = class {
232
+ session;
233
+ #entries = /* @__PURE__ */ new Map();
234
+ #disposed = false;
235
+ #onStaleSession;
236
+ constructor(session, onStaleSession) {
237
+ this.session = session;
238
+ this.#onStaleSession = onStaleSession;
239
+ }
240
+ get disposed() {
241
+ return this.#disposed;
242
+ }
243
+ snapshot(key) {
244
+ return this.#entries.get(hashKey(key))?.state ?? LOADING;
245
+ }
246
+ /** Starts observing `key`, reading it now unless a read is cached or in flight. */
247
+ subscribe(key, read, options, listener) {
248
+ if (this.#disposed) {
249
+ return () => {
250
+ };
251
+ }
252
+ const hash = hashKey(key);
253
+ let entry = this.#entries.get(hash);
254
+ if (!entry) {
255
+ entry = {
256
+ key,
257
+ read,
258
+ state: LOADING,
259
+ listeners: /* @__PURE__ */ new Set(),
260
+ intervals: /* @__PURE__ */ new Map(),
261
+ focus: /* @__PURE__ */ new Set(),
262
+ controller: null,
263
+ pending: null,
264
+ settledAt: 0,
265
+ version: 0,
266
+ timer: void 0,
267
+ unused: void 0
268
+ };
269
+ this.#entries.set(hash, entry);
270
+ }
271
+ const current = entry;
272
+ const token = {};
273
+ const unseen = current.listeners.size === 0;
274
+ current.read = read;
275
+ clearTimeout(current.unused);
276
+ current.listeners.add(listener);
277
+ if (options.intervalMs !== void 0 && options.intervalMs > 0) {
278
+ current.intervals.set(token, options.intervalMs);
279
+ }
280
+ if (options.refetchOnFocus) {
281
+ current.focus.add(token);
282
+ }
283
+ if (!current.pending && (current.settledAt === 0 || unseen && Date.now() - current.settledAt >= STALE_MS)) {
284
+ void this.#fetch(current, false);
285
+ } else {
286
+ this.#schedule(current);
287
+ }
288
+ return () => {
289
+ current.listeners.delete(listener);
290
+ current.intervals.delete(token);
291
+ current.focus.delete(token);
292
+ if (current.listeners.size === 0 && !this.#disposed) {
293
+ clearTimeout(current.timer);
294
+ current.unused = setTimeout(() => {
295
+ if (current.listeners.size === 0) {
296
+ this.#drop(hash, current);
297
+ }
298
+ }, UNUSED_MS);
299
+ }
300
+ };
301
+ }
302
+ /** Reads `key` again now, replacing any read in flight. */
303
+ refresh(key) {
304
+ const entry = this.#entries.get(hashKey(key));
305
+ return entry ? this.#fetch(entry, true) : Promise.resolve();
306
+ }
307
+ /**
308
+ * Marks every query under each prefix out of date: those on screen are read again at once, the
309
+ * rest are dropped. A read in flight is replaced, because it may have started before the change.
310
+ * No prefixes means every query.
311
+ */
312
+ invalidate(prefixes) {
313
+ const reads = [];
314
+ for (const [hash, entry] of this.#entries) {
315
+ if (prefixes.length > 0 && !prefixes.some((prefix) => startsWith(entry.key, prefix))) {
316
+ continue;
317
+ }
318
+ if (entry.listeners.size > 0) {
319
+ reads.push(this.#fetch(entry, true));
320
+ } else {
321
+ this.#drop(hash, entry);
322
+ }
323
+ }
324
+ return Promise.all(reads).then(() => {
325
+ });
326
+ }
327
+ /**
328
+ * Changes the cached value of `key` with `update`, unless a read wrote a newer value since
329
+ * `version` (from `version(key)`). Returns whether it applied.
330
+ */
331
+ update(key, version, update) {
332
+ const entry = this.#entries.get(hashKey(key));
333
+ if (this.#disposed || !entry || entry.version !== version || entry.state.status !== "ready") {
334
+ return false;
335
+ }
336
+ entry.version++;
337
+ this.#write(entry, {
338
+ status: "ready",
339
+ data: update(entry.state.data),
340
+ error: null
341
+ });
342
+ return true;
343
+ }
344
+ /**
345
+ * Replaces the cached value of `key` with one a change returned, cancelling a read in flight that
346
+ * may have started before the change. Does nothing for a key nobody has read.
347
+ */
348
+ set(key, data) {
349
+ const entry = this.#entries.get(hashKey(key));
350
+ if (this.#disposed || !entry) {
351
+ return;
352
+ }
353
+ entry.controller?.abort();
354
+ entry.controller = null;
355
+ entry.pending = null;
356
+ entry.version++;
357
+ entry.settledAt = Date.now();
358
+ this.#write(entry, { status: "ready", data, error: null });
359
+ this.#schedule(entry);
360
+ }
361
+ version(key) {
362
+ return this.#entries.get(hashKey(key))?.version ?? -1;
363
+ }
364
+ /** The tab became visible again: read whatever came due while it was hidden. */
365
+ resume() {
366
+ for (const entry of this.#entries.values()) {
367
+ this.#schedule(entry);
368
+ }
369
+ }
370
+ /** The window regained focus. */
371
+ focus() {
372
+ for (const entry of this.#entries.values()) {
373
+ if (entry.focus.size > 0 && entry.listeners.size > 0) {
374
+ void this.#fetch(entry, false);
375
+ }
376
+ }
377
+ }
378
+ /** Stops every read and timer for good. Answers that arrive later are dropped. */
379
+ dispose() {
380
+ this.#disposed = true;
381
+ for (const [hash, entry] of this.#entries) {
382
+ this.#drop(hash, entry);
383
+ }
384
+ }
385
+ #drop(hash, entry) {
386
+ entry.controller?.abort();
387
+ entry.controller = null;
388
+ entry.pending = null;
389
+ clearTimeout(entry.timer);
390
+ clearTimeout(entry.unused);
391
+ if (this.#entries.get(hash) === entry) {
392
+ this.#entries.delete(hash);
393
+ }
394
+ }
395
+ #write(entry, state) {
396
+ entry.state = state;
397
+ for (const listener of [...entry.listeners]) {
398
+ listener();
399
+ }
400
+ }
401
+ #fetch(entry, replace) {
402
+ if (this.#disposed) {
403
+ return Promise.resolve();
404
+ }
405
+ if (entry.pending && !replace) {
406
+ return entry.pending;
407
+ }
408
+ entry.controller?.abort();
409
+ clearTimeout(entry.timer);
410
+ const controller = new AbortController();
411
+ entry.controller = controller;
412
+ const counts = () => !this.#disposed && entry.controller === controller;
413
+ const pending = (async () => {
414
+ try {
415
+ const data = await Promise.resolve().then(
416
+ () => entry.read({ signal: controller.signal, session: this.session })
417
+ );
418
+ if (counts()) {
419
+ entry.version++;
420
+ this.#write(entry, { status: "ready", data, error: null });
421
+ }
422
+ } catch (cause) {
423
+ if (!counts()) {
424
+ return;
425
+ }
426
+ const error = relayError(cause);
427
+ if (error.code === RelayErrorCode.StaleSession) {
428
+ this.#onStaleSession();
429
+ }
430
+ const gone = error.kind === "http" && error.status === 404 || error.code === RelayErrorCode.StaleSession;
431
+ entry.version++;
432
+ this.#write(
433
+ entry,
434
+ entry.state.status === "ready" && !gone ? { status: "ready", data: entry.state.data, error } : { status: "error", data: void 0, error }
435
+ );
436
+ } finally {
437
+ if (counts()) {
438
+ entry.controller = null;
439
+ entry.pending = null;
440
+ entry.settledAt = Date.now();
441
+ this.#schedule(entry);
442
+ }
443
+ }
444
+ })();
445
+ entry.pending = pending;
446
+ return pending;
447
+ }
448
+ /** Arms the poll for the shortest interval anyone on screen asked for. */
449
+ #schedule(entry) {
450
+ clearTimeout(entry.timer);
451
+ if (this.#disposed || entry.pending || entry.listeners.size === 0 || entry.intervals.size === 0) {
452
+ return;
453
+ }
454
+ const interval = Math.min(...entry.intervals.values());
455
+ const wait = Math.max(0, entry.settledAt + interval - Date.now());
456
+ entry.timer = setTimeout(() => {
457
+ if (visible()) {
458
+ void this.#fetch(entry, false);
459
+ }
460
+ }, wait);
461
+ }
462
+ };
463
+
464
+ // src/react/keys.ts
465
+ var relayKeys = {
466
+ /** `useExperience`: invalidating it reads the quest experience again. */
467
+ experience: ["experience"],
468
+ /** `useMe`. */
469
+ me: ["me"],
470
+ /** `useAccounts`. */
471
+ accounts: ["accounts"],
472
+ /** `useWallets`. */
473
+ wallets: ["wallets"],
474
+ /** `useMyProfile`, `useProfile`, `useProfileCompletions`, `useProfileReferral`. */
475
+ profiles: ["profiles"],
476
+ /** `useLeaderboard`, `useLeaderboardPlacement`. */
477
+ leaderboards: ["leaderboards"],
478
+ /** `useReferralSummary`. */
479
+ referrals: ["referrals"],
480
+ /** `usePointLedgers`. */
481
+ pointLedgers: ["point-ledgers"],
482
+ /** `useTieredRewards`. */
483
+ tieredRewards: ["tiered-rewards"],
484
+ /** `useSignInMethods`. */
485
+ signInMethods: ["sign-in-methods"],
486
+ /** `useBalances`, `usePointHistory`. */
487
+ balances: ["balances"]
488
+ };
489
+ var completionChanges = [
490
+ relayKeys.balances,
491
+ relayKeys.leaderboards,
492
+ relayKeys.profiles,
493
+ relayKeys.referrals
494
+ ];
495
+
127
496
  // src/react/status.ts
128
497
  function isFinished(view) {
129
498
  return view.availability.kind === "completed" || view.completion.kind !== "keyed" && view.completed;
@@ -148,112 +517,125 @@ function visibleQuests(quests) {
148
517
  }
149
518
  function questStatus(view) {
150
519
  if (isFinished(view)) {
151
- return { tone: "done", label: "Completed" };
520
+ return { tone: "done", code: "completed" };
152
521
  }
153
522
  if (view.lifecycle === "paused") {
154
- return {
155
- tone: "off",
156
- label: "Paused",
157
- detail: "This activity is paused for now."
158
- };
523
+ return { tone: "off", code: "paused" };
159
524
  }
160
525
  if (view.lifecycle === "archived") {
161
- return {
162
- tone: "off",
163
- label: "Closed",
164
- detail: "This activity is no longer running."
165
- };
526
+ return { tone: "off", code: "closed" };
166
527
  }
167
528
  const availability = view.availability;
168
529
  const ownSubmission = view.trigger.kind === "manual" && view.trigger.actor !== "staff";
169
530
  const submissionFirst = ownSubmission && (availability.kind === "checking" || availability.kind === "needs-review" || availability.kind === "retryable");
170
531
  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
- };
532
+ const rejected = ownSubmission && view.attempt?.status === "rejected" ? { reason: view.attempt.reason ?? "" } : {};
533
+ if (view.streak.milestone.status === "budget-exhausted") {
534
+ return { tone: "attention", code: "streak-budget-exhausted", ...rejected };
179
535
  }
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 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
536
  return {
190
- tone: rejection ? "attention" : streak.todayQualified ? "waiting" : "open",
191
- label: `${streak.current} / ${streak.goal} days`,
192
- detail: rejection ? `${rejection} ${progress}` : progress
537
+ tone: "reason" in rejected ? "attention" : view.streak.todayQualified ? "waiting" : "open",
538
+ code: "streak",
539
+ ...rejected
193
540
  };
194
541
  }
195
542
  if (runsSilently(view) && availability.kind !== "locked") {
196
- 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."
203
- };
543
+ return { tone: "waiting", code: "automatic" };
204
544
  }
205
545
  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
- };
546
+ return { tone: "waiting", code: "in-progress" };
211
547
  }
212
548
  switch (availability.kind) {
213
549
  case "completed":
214
- return { tone: "done", label: "Completed" };
550
+ return { tone: "done", code: "completed" };
215
551
  case "locked":
216
- return {
217
- tone: "locked",
218
- label: "Locked",
219
- detail: `Complete ${list(availability.missing.map((m) => m.title))} first.`
220
- };
552
+ return { tone: "locked", code: "locked" };
221
553
  case "checking":
222
- return {
223
- tone: "waiting",
224
- label: "Checking",
225
- detail: "Your submission is being verified."
226
- };
554
+ return { tone: "waiting", code: "checking" };
227
555
  case "needs-review":
228
- return {
229
- tone: "waiting",
230
- label: "In review",
231
- detail: "A person is reviewing your submission."
232
- };
556
+ return { tone: "waiting", code: "in-review" };
233
557
  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
- };
558
+ return { tone: "attention", code: "retryable" };
239
559
  case "staff-required":
240
- return {
241
- tone: "waiting",
242
- label: "Waiting for staff",
243
- detail: "A staff member confirms this activity for you."
244
- };
560
+ return { tone: "waiting", code: "staff-required" };
245
561
  case "automatic":
246
- return {
247
- tone: "waiting",
248
- label: "In progress",
249
- detail: "This updates by itself when you qualify."
250
- };
562
+ return { tone: "waiting", code: "in-progress" };
251
563
  case "available":
252
564
  return view.attempt?.status === "rejected" ? {
253
565
  tone: "attention",
254
- label: "Try again",
255
- detail: view.attempt.reason ?? "That submission wasn't accepted."
256
- } : { tone: "open", label: "Ready" };
566
+ code: "rejected",
567
+ reason: view.attempt.reason ?? ""
568
+ } : { tone: "open", code: "ready" };
569
+ }
570
+ }
571
+ function questStatusText(view) {
572
+ const status = questStatus(view);
573
+ const words = (label, detail) => detail === void 0 ? { ...status, label } : { ...status, label, detail };
574
+ const rejection = status.reason === void 0 ? null : sentence(status.reason || "That submission wasn't accepted.");
575
+ switch (status.code) {
576
+ case "completed":
577
+ return words("Completed");
578
+ case "paused":
579
+ return words("Paused", "This activity is paused for now.");
580
+ case "closed":
581
+ return words("Closed", "This activity is no longer running.");
582
+ case "streak-budget-exhausted":
583
+ return words(
584
+ "Milestone reached",
585
+ rejection ?? "Your qualifying days are saved. This milestone's reward budget was exhausted."
586
+ );
587
+ case "streak": {
588
+ const streak = view.streak;
589
+ const deadline = new Intl.DateTimeFormat("en", {
590
+ timeZone: streak.timeZone,
591
+ month: "short",
592
+ day: "numeric",
593
+ hour: "numeric",
594
+ minute: "2-digit",
595
+ timeZoneName: "short"
596
+ }).format(streak.nextBoundary);
597
+ 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.`;
598
+ return words(
599
+ `${streak.current} / ${streak.goal} days`,
600
+ rejection ? `${rejection} ${progress}` : progress
601
+ );
602
+ }
603
+ case "automatic":
604
+ return words(
605
+ "Automatic",
606
+ view.completion.kind === "keyed" ? [
607
+ completionCountLabel(view),
608
+ "This counts by itself each time you qualify."
609
+ ].filter(Boolean).join(". ") : "This updates by itself when you qualify."
610
+ );
611
+ case "in-progress":
612
+ return words("In progress", "This updates by itself when you qualify.");
613
+ case "locked":
614
+ return words(
615
+ "Locked",
616
+ view.availability.kind === "locked" ? `Complete ${list(view.availability.missing.map((m) => m.title))} first.` : void 0
617
+ );
618
+ case "checking":
619
+ return words("Checking", "Your submission is being verified.");
620
+ case "in-review":
621
+ return words("In review", "A person is reviewing your submission.");
622
+ case "retryable":
623
+ return words(
624
+ "Didn't go through",
625
+ "Verification couldn't run. Try again. Nothing was lost."
626
+ );
627
+ case "staff-required":
628
+ return words(
629
+ "Waiting for staff",
630
+ "A staff member confirms this activity for you."
631
+ );
632
+ case "rejected":
633
+ return words(
634
+ "Try again",
635
+ status.reason || "That submission wasn't accepted."
636
+ );
637
+ case "ready":
638
+ return words("Ready");
257
639
  }
258
640
  }
259
641
  function canSubmit(view) {
@@ -278,109 +660,289 @@ function list(items) {
278
660
  );
279
661
  }
280
662
 
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;
663
+ // src/react/store.ts
664
+ var SIGNED_OUT = Object.freeze({
665
+ status: "signed-out",
666
+ data: void 0,
667
+ error: null
668
+ });
669
+ function scopeKey(session) {
670
+ if (!session) {
671
+ return "visitor";
672
+ }
673
+ const principal = session.principal;
674
+ return JSON.stringify([
675
+ session.organization,
676
+ session.project,
677
+ session.environment,
678
+ principal.kind,
679
+ sessionMember(session) ?? principal.subject ?? null,
680
+ session.id
681
+ ]);
682
+ }
683
+ function isVisible() {
684
+ return typeof document === "undefined" || document.visibilityState !== "hidden";
685
+ }
686
+ var RelayStore = class {
687
+ client;
688
+ #session = LOADING;
689
+ #scope = null;
690
+ #experience = { owner: null, state: LOADING };
691
+ #experienceView = LOADING;
692
+ #listeners = /* @__PURE__ */ new Set();
693
+ #observer = null;
694
+ #starting = false;
695
+ #mounted = false;
696
+ /** Bumped by every session the store learns of, so an older read cannot overwrite a newer one. */
697
+ #sessionVersion = 0;
698
+ #sessionRead = null;
699
+ /** The completions the experience last showed, and for whom. */
700
+ #completions = null;
701
+ constructor(client) {
702
+ this.client = client;
703
+ }
704
+ /** `refreshSession` with one identity per store, for hooks to hand out as `refresh`. */
705
+ rereadSession = () => this.refreshSession();
706
+ subscribe = (listener) => {
707
+ this.#listeners.add(listener);
708
+ return () => {
709
+ this.#listeners.delete(listener);
710
+ };
711
+ };
712
+ get session() {
713
+ return this.#session;
714
+ }
715
+ get scope() {
716
+ return this.#scope;
717
+ }
718
+ get experience() {
719
+ return this.#experienceView;
720
+ }
721
+ /** Starts observing; the returned function stops every read, timer and listener. */
722
+ mount() {
723
+ this.#mounted = true;
724
+ if (isVisible()) {
725
+ this.#startObserver();
288
726
  }
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();
727
+ void this.refreshSession();
728
+ const onVisibility = () => {
729
+ if (isVisible()) {
730
+ this.#startObserver();
731
+ this.#scope?.cache.resume();
732
+ } else {
733
+ this.#observer?.dispose();
734
+ this.#observer = null;
296
735
  }
297
736
  };
298
- window.addEventListener("focus", wake);
299
- document.addEventListener("visibilitychange", wake);
737
+ const onFocus = () => this.#scope?.cache.focus();
738
+ if (typeof window !== "undefined") {
739
+ document.addEventListener("visibilitychange", onVisibility);
740
+ window.addEventListener("focus", onFocus);
741
+ }
300
742
  return () => {
301
- watch.dispose();
302
- window.removeEventListener("focus", wake);
303
- document.removeEventListener("visibilitychange", wake);
743
+ this.#mounted = false;
744
+ if (typeof window !== "undefined") {
745
+ document.removeEventListener("visibilitychange", onVisibility);
746
+ window.removeEventListener("focus", onFocus);
747
+ }
748
+ this.#observer?.dispose();
749
+ this.#observer = null;
750
+ this.#sessionRead?.abort();
751
+ this.#sessionRead = null;
752
+ this.#sessionVersion++;
753
+ this.#scope?.cache.dispose();
754
+ this.#scope = null;
755
+ this.#session = LOADING;
756
+ this.#experience = { owner: null, state: LOADING };
757
+ this.#emit();
304
758
  };
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);
759
+ }
760
+ /** Reads the session again now, without waiting for the experience observer. */
761
+ async refreshSession() {
762
+ if (!this.#mounted) {
763
+ return;
764
+ }
765
+ const version = ++this.#sessionVersion;
766
+ this.#sessionRead?.abort();
767
+ const controller = new AbortController();
768
+ this.#sessionRead = controller;
769
+ try {
770
+ const session = await this.client.auth.session({
771
+ signal: controller.signal
772
+ });
773
+ if (version === this.#sessionVersion && this.#mounted) {
774
+ const before = this.#scope?.key;
775
+ this.#setSession({ status: "ready", data: session, error: null });
776
+ this.#emit();
777
+ if (before !== void 0 && this.#scope?.key !== before) {
778
+ void this.#observer?.refresh();
779
+ }
326
780
  }
327
- if (observation.kind === "ready") {
328
- setOwner(observation.progress.member);
781
+ } catch (cause) {
782
+ if (version === this.#sessionVersion && this.#mounted) {
783
+ this.#failSession(relayError(cause));
784
+ this.#emit();
785
+ }
786
+ } finally {
787
+ if (this.#sessionRead === controller) {
788
+ this.#sessionRead = null;
329
789
  }
330
- setExperience((previous) => {
790
+ }
791
+ }
792
+ /** Writes a value a change returned into the current participant's cache (`QueryCache.set`). */
793
+ set(key, data) {
794
+ this.#scope?.cache.set(key, data);
795
+ }
796
+ refreshExperience() {
797
+ return this.#observer?.refresh() ?? Promise.resolve();
798
+ }
799
+ /**
800
+ * Marks queries out of date (see `QueryCache.invalidate`). The `["experience"]` key, or no keys at
801
+ * all, also reads the quest experience again.
802
+ */
803
+ invalidate(keys) {
804
+ const reads = [];
805
+ if (keys.length === 0 || keys.some((key) => key[0] === "experience")) {
806
+ reads.push(this.refreshExperience());
807
+ }
808
+ reads.push(this.#scope?.cache.invalidate(keys) ?? Promise.resolve());
809
+ return Promise.all(reads).then(() => {
810
+ });
811
+ }
812
+ #startObserver() {
813
+ if (this.#observer || !this.#mounted) {
814
+ return;
815
+ }
816
+ this.#starting = true;
817
+ try {
818
+ this.#observer = this.client.quests.observe((observation) => {
331
819
  switch (observation.kind) {
332
820
  case "loading":
333
- return { status: "loading" };
821
+ if (this.#starting) {
822
+ return;
823
+ }
824
+ this.#sessionVersion++;
825
+ this.#sessionRead?.abort();
826
+ return;
334
827
  case "session":
335
- return observation.session?.principal.kind === "member" ? previous : { status: "signed-out" };
828
+ this.#sessionVersion++;
829
+ this.#setSession({
830
+ status: "ready",
831
+ data: observation.session,
832
+ error: null
833
+ });
834
+ break;
336
835
  case "ready":
337
- return {
836
+ this.#sessionVersion++;
837
+ this.#setSession({
338
838
  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
839
+ data: observation.session,
840
+ error: null
841
+ });
842
+ this.#completed(observation.progress);
843
+ this.#experience = {
844
+ owner: observation.progress.member,
845
+ state: {
846
+ status: "ready",
847
+ // Hidden quests never reach the UI, on any poll.
848
+ data: {
849
+ quests: visibleQuests(observation.progress.quests),
850
+ collections: observation.progress.collections.map(
851
+ (collection) => ({
852
+ ...collection,
853
+ quests: visibleQuests(collection.quests)
854
+ })
855
+ )
856
+ },
857
+ error: null
858
+ }
859
+ };
860
+ break;
861
+ case "error": {
862
+ const error = relayError(observation.error);
863
+ const previous = this.#experience;
864
+ this.#experience = previous.state.status === "ready" ? {
865
+ owner: previous.owner,
866
+ state: { ...previous.state, error }
867
+ } : {
868
+ owner: this.#scope?.member ?? null,
869
+ state: { status: "error", data: void 0, error }
348
870
  };
349
- case "error":
350
- return previous.status === "ready" ? { ...previous, stale: observation.error.message } : { status: "error", message: observation.error.message };
871
+ if (this.#session.status === "loading") {
872
+ void this.refreshSession();
873
+ }
874
+ break;
875
+ }
351
876
  }
877
+ this.#emit();
352
878
  });
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>");
879
+ } finally {
880
+ this.#starting = false;
881
+ }
381
882
  }
382
- return value;
383
- }
883
+ /**
884
+ * A new completion changes points, scores, profile completions and referral progress, whether
885
+ * the participant submitted it or Domino completed it by itself: read them again.
886
+ */
887
+ #completed(progress) {
888
+ const mark = completionMark([
889
+ ...progress.quests,
890
+ ...progress.collections.flatMap((collection) => collection.quests)
891
+ ]);
892
+ const previous = this.#completions;
893
+ this.#completions = { member: progress.member, mark };
894
+ if (previous?.member === progress.member && previous.mark !== mark) {
895
+ void this.#scope?.cache.invalidate(completionChanges);
896
+ }
897
+ }
898
+ #failSession(error) {
899
+ this.#setSession(
900
+ this.#session.status === "ready" ? { ...this.#session, error } : { status: "error", data: void 0, error }
901
+ );
902
+ }
903
+ #setSession(next) {
904
+ this.#session = next;
905
+ const key = next.status === "loading" ? null : next.status === "error" ? "unknown" : scopeKey(next.data);
906
+ if (key === (this.#scope?.key ?? null)) {
907
+ return;
908
+ }
909
+ this.#scope?.cache.dispose();
910
+ if (key === null) {
911
+ this.#scope = null;
912
+ return;
913
+ }
914
+ const session = next.status === "ready" ? next.data : null;
915
+ this.#scope = {
916
+ key,
917
+ session,
918
+ member: sessionMember(session),
919
+ cache: new QueryCache(session?.id, () => void this.refreshSession())
920
+ };
921
+ }
922
+ #deriveExperience() {
923
+ const session = this.#session;
924
+ if (session.status === "loading") {
925
+ return LOADING;
926
+ }
927
+ if (session.status === "error") {
928
+ return session;
929
+ }
930
+ const member = this.#scope?.member ?? null;
931
+ if (!member) {
932
+ return SIGNED_OUT;
933
+ }
934
+ return this.#experience.owner === member ? this.#experience.state : LOADING;
935
+ }
936
+ #emit() {
937
+ const experience = this.#deriveExperience();
938
+ if (experience !== this.#experienceView) {
939
+ this.#experienceView = experience;
940
+ }
941
+ for (const listener of [...this.#listeners]) {
942
+ listener();
943
+ }
944
+ }
945
+ };
384
946
 
385
947
  // src/react/provider.tsx
386
948
  import { jsx as jsx2 } from "react/jsx-runtime";
@@ -389,10 +951,234 @@ function RelayProvider({
389
951
  baseUrl = "/relay",
390
952
  children
391
953
  }) {
392
- const [value] = useState3(
393
- () => client ?? createBrowserRelayClient({ baseUrl })
954
+ const origin = client ? null : baseUrl;
955
+ const store = useMemo2(
956
+ () => new RelayStore(
957
+ client ?? createBrowserRelayClient({ baseUrl: origin ?? baseUrl })
958
+ ),
959
+ // A given client ignores `baseUrl`.
960
+ [client, origin]
961
+ );
962
+ useEffect2(() => store.mount(), [store]);
963
+ return /* @__PURE__ */ jsx2(StoreContext, { value: store, children: /* @__PURE__ */ jsx2(DeliveriesProvider, { children }) });
964
+ }
965
+
966
+ // src/react/query.ts
967
+ import {
968
+ useCallback as useCallback2,
969
+ useEffect as useEffect3,
970
+ useLayoutEffect,
971
+ useMemo as useMemo3,
972
+ useRef,
973
+ useState as useState2,
974
+ useSyncExternalStore as useSyncExternalStore2
975
+ } from "react";
976
+ function useLatest(value) {
977
+ const ref = useRef(value);
978
+ useLayoutEffect(() => {
979
+ ref.current = value;
980
+ });
981
+ return ref;
982
+ }
983
+ function useSessionSnapshot(store) {
984
+ const get = () => store.session;
985
+ return useSyncExternalStore2(store.subscribe, get, get);
986
+ }
987
+ function useScope(store) {
988
+ const get = () => store.scope;
989
+ return useSyncExternalStore2(store.subscribe, get, get);
990
+ }
991
+ function useCached(cache, key, read, options) {
992
+ const hash = key ? hashKey(key) : null;
993
+ const { intervalMs, refetchOnFocus } = options;
994
+ const latest = useLatest({ key, read });
995
+ const subscribe = useCallback2(
996
+ (listener) => {
997
+ const { key: current } = latest.current;
998
+ if (!cache || !current) {
999
+ return () => {
1000
+ };
1001
+ }
1002
+ return cache.subscribe(
1003
+ current,
1004
+ (context) => latest.current.read(context),
1005
+ { intervalMs, refetchOnFocus },
1006
+ listener
1007
+ );
1008
+ },
1009
+ // `hash` stands for the key.
1010
+ [cache, hash, intervalMs, refetchOnFocus]
1011
+ );
1012
+ const get = () => cache && key ? cache.snapshot(key) : LOADING;
1013
+ const snapshot = useSyncExternalStore2(subscribe, get, get);
1014
+ const refresh = useCallback2(
1015
+ () => cache && latest.current.key ? cache.refresh(latest.current.key) : Promise.resolve(),
1016
+ [cache, hash]
1017
+ );
1018
+ return useMemo3(() => ({ ...snapshot, refresh }), [snapshot, refresh]);
1019
+ }
1020
+ function useRelayQuery(key, read, options = {}) {
1021
+ const store = useStore();
1022
+ const scope = useScope(store);
1023
+ return useCached(scope?.cache ?? null, key, read, options);
1024
+ }
1025
+ function useMemberQuery(key, read, options = {}) {
1026
+ const store = useStore();
1027
+ const scope = useScope(store);
1028
+ const member = scope?.member ?? null;
1029
+ const query = useCached(
1030
+ member ? scope.cache : null,
1031
+ member ? key : null,
1032
+ read,
1033
+ options
1034
+ );
1035
+ return useMemberGate(store, member, query);
1036
+ }
1037
+ function useMemberGate(store, member, query) {
1038
+ const session = useSessionSnapshot(store);
1039
+ return useMemo3(() => {
1040
+ if (session.status === "loading") {
1041
+ return { ...LOADING, refresh: query.refresh };
1042
+ }
1043
+ if (session.status === "error") {
1044
+ return {
1045
+ status: "error",
1046
+ data: void 0,
1047
+ error: session.error,
1048
+ refresh: store.rereadSession
1049
+ };
1050
+ }
1051
+ return member ? query : { ...SIGNED_OUT, refresh: query.refresh };
1052
+ }, [session, member, query, store]);
1053
+ }
1054
+ function usePages(cache, key, read, options) {
1055
+ const hash = key ? hashKey(key) : null;
1056
+ const query = useCached(
1057
+ cache,
1058
+ key,
1059
+ (context) => read(context),
1060
+ options
1061
+ );
1062
+ const [more, setMore] = useState2(null);
1063
+ const busy = useRef(null);
1064
+ const latest = useLatest({ key, read, query });
1065
+ useEffect3(
1066
+ () => () => {
1067
+ busy.current?.abort();
1068
+ busy.current = null;
1069
+ },
1070
+ [cache, hash]
1071
+ );
1072
+ const loadMore = useCallback2(async () => {
1073
+ const { key: current, read: reader, query: shown } = latest.current;
1074
+ if (!cache || !current || busy.current || shown.status !== "ready" || shown.data.nextCursor === null) {
1075
+ return;
1076
+ }
1077
+ const owner = { cache, hash };
1078
+ const version = cache.version(current);
1079
+ const controller = new AbortController();
1080
+ busy.current = controller;
1081
+ setMore({ owner, loading: true, error: null });
1082
+ try {
1083
+ const page = await reader({
1084
+ signal: controller.signal,
1085
+ session: cache.session,
1086
+ cursor: shown.data.nextCursor
1087
+ });
1088
+ if (busy.current === controller) {
1089
+ cache.update(current, version, (data) => ({
1090
+ items: [...data.items, ...page.items],
1091
+ nextCursor: page.nextCursor
1092
+ }));
1093
+ }
1094
+ } catch (cause) {
1095
+ if (busy.current === controller) {
1096
+ setMore({ owner, loading: false, error: relayError(cause) });
1097
+ }
1098
+ } finally {
1099
+ if (busy.current === controller) {
1100
+ busy.current = null;
1101
+ setMore(
1102
+ (value) => value?.owner === owner ? { ...value, loading: false } : value
1103
+ );
1104
+ }
1105
+ }
1106
+ }, [cache, hash]);
1107
+ const mine = more && more.owner.cache === cache && more.owner.hash === hash ? more : null;
1108
+ return useMemo3(
1109
+ () => ({
1110
+ ...query,
1111
+ loadMore,
1112
+ loadingMore: mine?.loading ?? false,
1113
+ loadMoreError: mine?.error ?? null
1114
+ }),
1115
+ [query, loadMore, mine]
1116
+ );
1117
+ }
1118
+ function useRelayPages(key, read, options = {}) {
1119
+ const store = useStore();
1120
+ const scope = useScope(store);
1121
+ return usePages(scope?.cache ?? null, key, read, options);
1122
+ }
1123
+ function useMemberPages(key, read, options = {}) {
1124
+ const store = useStore();
1125
+ const scope = useScope(store);
1126
+ const member = scope?.member ?? null;
1127
+ const pages = usePages(
1128
+ member ? scope.cache : null,
1129
+ member ? key : null,
1130
+ read,
1131
+ options
1132
+ );
1133
+ const gate = useMemberGate(store, member, pages);
1134
+ return useMemo3(
1135
+ () => gate === pages ? pages : {
1136
+ ...gate,
1137
+ loadMore: pages.loadMore,
1138
+ loadingMore: false,
1139
+ loadMoreError: null
1140
+ },
1141
+ [gate, pages]
1142
+ );
1143
+ }
1144
+ function useInvalidate() {
1145
+ const store = useStore();
1146
+ return useCallback2((keys = []) => store.invalidate(keys), [store]);
1147
+ }
1148
+ function useSession() {
1149
+ const store = useStore();
1150
+ const session = useSessionSnapshot(store);
1151
+ const refresh = store.rereadSession;
1152
+ return useMemo3(
1153
+ () => ({
1154
+ ...session,
1155
+ refresh,
1156
+ member: session.status === "ready" ? sessionMember(session.data) : null
1157
+ }),
1158
+ [session, refresh]
394
1159
  );
395
- return /* @__PURE__ */ jsx2(ClientContext, { value, children: /* @__PURE__ */ jsx2(ExperienceProvider, { children }) });
1160
+ }
1161
+ function useAction() {
1162
+ const [busy, setBusy] = useState2(false);
1163
+ const [actionError, setActionError] = useState2(null);
1164
+ const run = useCallback2(async (task) => {
1165
+ setBusy(true);
1166
+ setActionError(null);
1167
+ try {
1168
+ await task();
1169
+ return true;
1170
+ } catch (cause) {
1171
+ setActionError(relayError(cause));
1172
+ return false;
1173
+ } finally {
1174
+ setBusy(false);
1175
+ }
1176
+ }, []);
1177
+ return { busy, actionError, run };
1178
+ }
1179
+ function useScopeSession() {
1180
+ const store = useStore();
1181
+ return useScope(store)?.session?.id;
396
1182
  }
397
1183
 
398
1184
  // src/react/quest-groups.ts
@@ -416,24 +1202,24 @@ function questGroups(experience, order = []) {
416
1202
  }
417
1203
 
418
1204
  // src/react/use-quest.ts
419
- import { useCallback, useEffect as useEffect3, useState as useState4 } from "react";
1205
+ import { useCallback as useCallback3, useEffect as useEffect4, useState as useState3 } from "react";
420
1206
  function useQuest(observed) {
421
- const relay = useRelay();
422
- const { refresh } = useExperience();
423
- const [latest, setLatest] = useState4(null);
424
- const [draft, setDraft] = useState4(null);
425
- const [notice, setNotice] = useState4(null);
426
- const [error, setError] = useState4(null);
427
- const [busy, setBusy] = useState4(false);
428
- const [discardedAttempt, setDiscardedAttempt] = useState4(null);
429
- const [saved, setSaved] = useState4("none");
430
- const [receipt, setReceipt] = useState4(null);
431
- useEffect3(() => setLatest(null), [observed.release]);
1207
+ const store = useStore();
1208
+ const relay = store.client;
1209
+ const [latest, setLatest] = useState3(null);
1210
+ const [draft, setDraft] = useState3(null);
1211
+ const [notice, setNotice] = useState3(null);
1212
+ const [error, setError] = useState3(null);
1213
+ const [busy, setBusy] = useState3(false);
1214
+ const [discardedAttempt, setDiscardedAttempt] = useState3(null);
1215
+ const [saved, setSaved] = useState3("none");
1216
+ const [receipt, setReceipt] = useState3(null);
1217
+ useEffect4(() => setLatest(null), [observed.release]);
432
1218
  const view = latest ?? observed;
433
1219
  const { quest, release } = view;
434
1220
  const completed = isFinished(view) || isAutomatic(view);
435
1221
  const attempt = view.attempt?.id;
436
- useEffect3(() => {
1222
+ useEffect4(() => {
437
1223
  let active = true;
438
1224
  relay.quests.open(view).then((controller) => controller.status()).then((status) => {
439
1225
  if (!active) {
@@ -448,7 +1234,7 @@ function useQuest(observed) {
448
1234
  active = false;
449
1235
  };
450
1236
  }, [relay, quest, release, completed, attempt, busy]);
451
- const run = useCallback(
1237
+ const run = useCallback3(
452
1238
  async (action) => {
453
1239
  setBusy(true);
454
1240
  setError(null);
@@ -464,16 +1250,14 @@ function useQuest(observed) {
464
1250
  setReceipt(result.attempt.id);
465
1251
  setNotice("received");
466
1252
  }
467
- await refresh();
1253
+ await store.refreshExperience();
468
1254
  } catch (cause) {
469
- setError(
470
- cause instanceof Error ? cause.message : "Something went wrong."
471
- );
1255
+ setError(asRelayError(cause, "Something went wrong."));
472
1256
  } finally {
473
1257
  setBusy(false);
474
1258
  }
475
1259
  },
476
- [refresh]
1260
+ [store]
477
1261
  );
478
1262
  return {
479
1263
  /** The newest known version of the quest. Render this, not the prop. */
@@ -497,9 +1281,7 @@ function useQuest(observed) {
497
1281
  setDraft(null);
498
1282
  setNotice(null);
499
1283
  } catch (cause) {
500
- setError(
501
- cause instanceof Error ? cause.message : "Could not start a new submission."
502
- );
1284
+ setError(asRelayError(cause, "Could not start a new submission."));
503
1285
  } finally {
504
1286
  setBusy(false);
505
1287
  }
@@ -550,72 +1332,41 @@ function useQuest(observed) {
550
1332
  }
551
1333
 
552
1334
  // src/react/auth.ts
553
- import { useCallback as useCallback3, useEffect as useEffect5, useMemo as useMemo2, useRef as useRef2, useState as useState6 } from "react";
1335
+ import { useCallback as useCallback4, useMemo as useMemo5, useRef as useRef2, useState as useState5 } from "react";
554
1336
 
555
1337
  // src/react/wallets.ts
556
- import { useCallback as useCallback2, useEffect as useEffect4, useState as useState5 } from "react";
557
- var WALLETS_CHANGED = "relay-wallets-changed";
1338
+ import { useEffect as useEffect5, useMemo as useMemo4, useState as useState4 } from "react";
558
1339
  function useWallets() {
559
1340
  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);
1341
+ const invalidate = useInvalidate();
1342
+ const session = useScopeSession();
1343
+ const query = useMemberQuery(
1344
+ relayKeys.wallets,
1345
+ (options) => relay.wallets.list(options),
1346
+ { refetchOnFocus: true }
1347
+ );
1348
+ const { busy, actionError, run } = useAction();
1349
+ const actions = useMemo4(() => {
1350
+ const changed = () => void invalidate([relayKeys.wallets, relayKeys.experience]);
1351
+ return {
1352
+ /** Links the wallet to the member the list was read for; another session is refused. */
1353
+ link: (signer) => run(async () => {
1354
+ await relay.wallets.link(signer, { session });
1355
+ changed();
1356
+ }),
1357
+ unlink: (wallet) => run(async () => {
1358
+ await relay.wallets.unlink(
1359
+ { chain: wallet.chain, address: wallet.address },
1360
+ { session }
1361
+ );
1362
+ changed();
1363
+ })
586
1364
  };
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
- };
1365
+ }, [relay, invalidate, session, run]);
1366
+ return useMemo4(
1367
+ () => ({ ...query, busy, actionError, ...actions }),
1368
+ [query, busy, actionError, actions]
1369
+ );
619
1370
  }
620
1371
  function discoverChains(key) {
621
1372
  return JSON.parse(key).flatMap(
@@ -624,8 +1375,8 @@ function discoverChains(key) {
624
1375
  }
625
1376
  function useWalletDiscovery(options = {}) {
626
1377
  const chains = JSON.stringify(cosmosChainList(options.cosmos));
627
- const [found, setFound] = useState5({ evm: [], solana: [], cosmos: [] });
628
- useEffect4(() => {
1378
+ const [found, setFound] = useState4({ evm: [], solana: [], cosmos: [] });
1379
+ useEffect5(() => {
629
1380
  let active = true;
630
1381
  const solana = discoverSolanaWallets();
631
1382
  const cosmos = discoverChains(chains);
@@ -647,8 +1398,8 @@ function useSignInWallets(networks, options = {}) {
647
1398
  );
648
1399
  const key = networks ? `${networks.join(" ")}
649
1400
  ${chains}` : null;
650
- const [found, setFound] = useState5(null);
651
- useEffect4(() => {
1401
+ const [found, setFound] = useState4(null);
1402
+ useEffect5(() => {
652
1403
  if (key === null) {
653
1404
  return;
654
1405
  }
@@ -672,71 +1423,44 @@ ${chains}` : null;
672
1423
  // src/react/auth.ts
673
1424
  function useSignInMethods() {
674
1425
  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;
1426
+ return useRelayQuery(relayKeys.signInMethods, () => readSignInMethods(relay));
688
1427
  }
689
1428
  function useMe() {
690
1429
  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;
1430
+ return useMemberQuery(relayKeys.me, (options) => relay.me.get(options));
705
1431
  }
706
1432
  function useSignIn() {
707
1433
  const relay = useRelay();
708
- const [challenge, setChallengeState] = useState6(null);
1434
+ const [challenge, setChallengeState] = useState5(null);
709
1435
  const latest = useRef2(null);
710
- const [busy, setBusy] = useState6(false);
711
- const [error, setError] = useState6(null);
712
- const setChallenge = useCallback3((value) => {
1436
+ const [busy, setBusy] = useState5(false);
1437
+ const [error, setError] = useState5(null);
1438
+ const setChallenge = useCallback4((value) => {
713
1439
  latest.current = value;
714
1440
  setChallengeState(value);
715
1441
  }, []);
716
- const run = useCallback3(
1442
+ const run = useCallback4(
717
1443
  async (task, fallback) => {
718
1444
  setBusy(true);
719
1445
  setError(null);
720
1446
  try {
721
1447
  await task();
722
1448
  } catch (cause) {
723
- setError(
724
- cause instanceof Error && cause.message ? cause.message : fallback
725
- );
1449
+ setError(asRelayError(cause, fallback));
726
1450
  } finally {
727
1451
  setBusy(false);
728
1452
  }
729
1453
  },
730
1454
  []
731
1455
  );
732
- const sendCode = useCallback3(
1456
+ const sendCode = useCallback4(
733
1457
  (email) => run(async () => {
734
1458
  setChallenge(null);
735
1459
  setChallenge((await relay.auth.emailStart(email)).challenge);
736
1460
  }, "Couldn't send a code. Check the address and try again."),
737
1461
  [relay, run, setChallenge]
738
1462
  );
739
- const verifyCode = useCallback3(
1463
+ const verifyCode = useCallback4(
740
1464
  (code) => run(async () => {
741
1465
  const sent = latest.current;
742
1466
  if (!sent) {
@@ -747,18 +1471,18 @@ function useSignIn() {
747
1471
  }, "That code didn't work. Check it, or request a new one."),
748
1472
  [relay, run, setChallenge]
749
1473
  );
750
- const startOver = useCallback3(() => {
1474
+ const startOver = useCallback4(() => {
751
1475
  setChallenge(null);
752
1476
  setError(null);
753
1477
  }, [setChallenge]);
754
- const withProvider = useCallback3(
1478
+ const withProvider = useCallback4(
755
1479
  (provider) => run(async () => {
756
1480
  const { url } = await relay.auth.start(provider, "sign-in");
757
1481
  window.location.assign(url);
758
1482
  }, "Couldn't start sign-in. Try again."),
759
1483
  [relay, run]
760
1484
  );
761
- const withWallet = useCallback3(
1485
+ const withWallet = useCallback4(
762
1486
  (signer) => run(async () => {
763
1487
  await relay.auth.wallet.signIn(signer);
764
1488
  setChallenge(null);
@@ -780,7 +1504,7 @@ function useSignIn() {
780
1504
  }
781
1505
  function useSignOut() {
782
1506
  const relay = useRelay();
783
- const [busy, setBusy] = useState6(false);
1507
+ const [busy, setBusy] = useState5(false);
784
1508
  return {
785
1509
  busy,
786
1510
  signOut: async () => {
@@ -795,11 +1519,11 @@ function useSignOut() {
795
1519
  }
796
1520
  function useSignInOptions(input = {}) {
797
1521
  const chains = JSON.stringify(cosmosChainList(input.cosmos));
798
- const cosmos = useMemo2(
1522
+ const cosmos = useMemo5(
799
1523
  () => JSON.parse(chains),
800
1524
  [chains]
801
1525
  );
802
- const methods = useSignInMethods();
1526
+ const methods = useSignInMethods().data ?? null;
803
1527
  const wallets = useSignInWallets(methods?.wallets, { cosmos });
804
1528
  const {
805
1529
  busy,
@@ -811,7 +1535,7 @@ function useSignInOptions(input = {}) {
811
1535
  withProvider,
812
1536
  withWallet
813
1537
  } = useSignIn();
814
- const built = useMemo2(() => {
1538
+ const built = useMemo5(() => {
815
1539
  if (!methods) {
816
1540
  return null;
817
1541
  }
@@ -841,8 +1565,7 @@ function useSignInOptions(input = {}) {
841
1565
  }
842
1566
 
843
1567
  // src/react/accounts.ts
844
- import { useCallback as useCallback4, useEffect as useEffect6, useState as useState7 } from "react";
845
- var CHANGED = "relay-accounts-changed";
1568
+ import { useMemo as useMemo6 } from "react";
846
1569
  function isConnected(accounts, provider) {
847
1570
  return Boolean(
848
1571
  accounts?.some(
@@ -852,304 +1575,123 @@ function isConnected(accounts, provider) {
852
1575
  }
853
1576
  function useAccounts() {
854
1577
  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
- };
1578
+ const invalidate = useInvalidate();
1579
+ const session = useScopeSession();
1580
+ const query = useMemberQuery(
1581
+ relayKeys.accounts,
1582
+ (options) => relay.auth.accounts(options),
1583
+ { refetchOnFocus: true }
1584
+ );
1585
+ const { busy, actionError, run } = useAction();
1586
+ const actions = useMemo6(
1587
+ () => ({
1588
+ connect: (provider) => run(async () => {
1589
+ const { url } = await relay.auth.start(provider, "connect", {
1590
+ session
1591
+ });
1592
+ window.location.assign(url);
1593
+ }),
1594
+ disconnect: (provider) => run(async () => {
1595
+ await relay.auth.disconnect(provider, { session });
1596
+ void invalidate([
1597
+ relayKeys.accounts,
1598
+ relayKeys.experience,
1599
+ relayKeys.profiles
1600
+ ]);
1601
+ })
1602
+ }),
1603
+ [relay, invalidate, session, run]
1604
+ );
1605
+ const accounts = query.data;
1606
+ return useMemo6(
1607
+ () => ({
1608
+ ...query,
1609
+ busy,
1610
+ actionError,
1611
+ isConnected: (provider) => isConnected(accounts, provider),
1612
+ ...actions
1613
+ }),
1614
+ [query, busy, actionError, accounts, actions]
1615
+ );
912
1616
  }
913
1617
 
914
1618
  // 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";
1619
+ import { useMemo as useMemo7 } from "react";
1620
+ var MINE = [...relayKeys.profiles, "mine"];
917
1621
  function useMyProfile() {
918
1622
  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()
1623
+ const store = useStore();
1624
+ const invalidate = useInvalidate();
1625
+ const session = useScopeSession();
1626
+ const query = useMemberQuery(
1627
+ MINE,
1628
+ (options) => relay.profiles.mine(options),
1629
+ { refetchOnFocus: true }
1630
+ );
1631
+ const { busy, actionError, run } = useAction();
1632
+ const actions = useMemo7(() => {
1633
+ const change = (task) => run(async () => {
1634
+ store.set(MINE, await task());
1635
+ void invalidate([
1636
+ [...relayKeys.profiles, "get"],
1637
+ relayKeys.leaderboards
929
1638
  ]);
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);
1639
+ });
1640
+ return {
1641
+ update: (input) => change(() => relay.profiles.update(input, { session })),
1642
+ /** Uploads a JPEG, PNG or WebP of at most 5 MB as the avatar. */
1643
+ uploadAvatar: (file) => change(
1644
+ () => relay.profiles.uploadAvatar(file, {
1645
+ uploadId: crypto.randomUUID(),
1646
+ session
1647
+ })
1648
+ )
1025
1649
  };
1026
- }, [refresh, member, intervalMs]);
1027
- return { state, refresh };
1650
+ }, [relay, store, invalidate, session, run]);
1651
+ return useMemo7(
1652
+ () => ({ ...query, busy, actionError, ...actions }),
1653
+ [query, busy, actionError, actions]
1654
+ );
1028
1655
  }
1029
1656
  function useProfile(id, options = {}) {
1030
1657
  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."
1658
+ return useRelayQuery(
1659
+ [...relayKeys.profiles, "get", id],
1660
+ (request) => relay.profiles.get(id, request),
1661
+ { intervalMs: options.intervalMs ?? 6e4 }
1036
1662
  );
1037
- const value = state.status === "ready" ? { status: "ready", profile: state.value, stale: state.stale } : state;
1038
- return { ...value, refresh };
1039
1663
  }
1040
1664
  function useLeaderboardPlacement(board, profile, options = {}) {
1041
1665
  const relay = useRelay();
1042
- const read = useCallback5(
1043
- () => relay.leaderboards.placement(board, profile),
1044
- [relay, board, profile]
1045
- );
1046
- const { state, refresh } = useProfileRead(
1047
- read,
1048
- options.intervalMs ?? 3e4,
1049
- "Could not load the leaderboard placement."
1666
+ return useRelayQuery(
1667
+ [...relayKeys.leaderboards, board, "placement", profile],
1668
+ (request) => relay.leaderboards.placement(board, profile, request),
1669
+ { intervalMs: options.intervalMs ?? 3e4 }
1050
1670
  );
1051
- return { ...state, refresh };
1052
1671
  }
1053
1672
  function useProfileReferral(profile, program, options = {}) {
1054
1673
  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."
1674
+ return useMemberQuery(
1675
+ [...relayKeys.profiles, "referral", profile, program],
1676
+ (request) => relay.profiles.referral(profile, program, request),
1677
+ { intervalMs: options.intervalMs ?? 6e4 }
1063
1678
  );
1064
- return { ...state, refresh };
1065
1679
  }
1066
1680
  function useProfileCompletions(profile, options = {}) {
1067
1681
  const relay = useRelay();
1068
- const { member } = useExperience();
1069
1682
  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]
1683
+ return useRelayPages(
1684
+ [...relayKeys.profiles, "completions", profile, limit],
1685
+ ({ cursor, ...request }) => relay.profiles.completions(
1686
+ profile,
1687
+ cursor ? { limit, cursor } : { limit },
1688
+ request
1689
+ )
1128
1690
  );
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
1691
  }
1150
1692
 
1151
1693
  // src/react/referrals.ts
1152
- import { useCallback as useCallback6, useEffect as useEffect8, useRef as useRef4, useState as useState9 } from "react";
1694
+ import { useCallback as useCallback5, useEffect as useEffect6, useMemo as useMemo8, useRef as useRef3, useState as useState6 } from "react";
1153
1695
  var PENDING = "domino:pending-referral";
1154
1696
  var CODE = /^[a-zA-Z0-9_-]{1,100}$/;
1155
1697
  function captureReferralCode(param = "ref") {
@@ -1174,34 +1716,36 @@ function forgetReferralCode() {
1174
1716
  }
1175
1717
  function useInvitation(param = "ref") {
1176
1718
  const relay = useRelay();
1177
- const { member } = useExperience();
1178
- const [state, setState] = useState9(() => {
1719
+ const invalidate = useInvalidate();
1720
+ const { member } = useSession();
1721
+ const [state, setState] = useState6(() => {
1179
1722
  const code = captureReferralCode(param);
1180
1723
  return code ? { status: "pending", code } : { status: "none" };
1181
1724
  });
1182
- const started = useRef4(null);
1183
- const accept = useCallback6(
1725
+ const started = useRef3(null);
1726
+ const accept = useCallback5(
1184
1727
  async (code) => {
1185
1728
  setState({ status: "accepting", code });
1186
1729
  try {
1187
1730
  await relay.referrals.accept({ code, actionId: `accept-${code}` });
1188
1731
  forgetReferralCode();
1189
1732
  setState({ status: "accepted", code });
1733
+ void invalidate([relayKeys.referrals, relayKeys.profiles]);
1190
1734
  } 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);
1735
+ const refused = isRelayError(cause) && (cause.kind === "http" && cause.status !== void 0 && cause.status >= 400 && cause.status < 500 || cause.code === RelayErrorCode.InvalidInput);
1192
1736
  if (refused) {
1193
1737
  forgetReferralCode();
1194
1738
  }
1195
1739
  setState({
1196
1740
  status: refused ? "rejected" : "failed",
1197
1741
  code,
1198
- reason: cause instanceof Error && cause.message ? cause.message : "That invitation code couldn't be used."
1742
+ error: asRelayError(cause, "That invitation code couldn't be used.")
1199
1743
  });
1200
1744
  }
1201
1745
  },
1202
- [relay]
1746
+ [relay, invalidate]
1203
1747
  );
1204
- useEffect8(() => {
1748
+ useEffect6(() => {
1205
1749
  if (member && state.status === "pending" && started.current !== `${member}:${state.code}`) {
1206
1750
  started.current = `${member}:${state.code}`;
1207
1751
  void accept(state.code);
@@ -1211,255 +1755,90 @@ function useInvitation(param = "ref") {
1211
1755
  }
1212
1756
  function useReferralSummary(options = {}) {
1213
1757
  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();
1758
+ const query = useMemberQuery(
1759
+ [...relayKeys.referrals, "summary"],
1760
+ (request) => relay.referrals.summary(request)
1761
+ );
1762
+ const { path = "/", param = "ref" } = options;
1763
+ return useMemo8(() => {
1764
+ let link = null;
1765
+ if (query.data && typeof window !== "undefined") {
1766
+ const url = new URL(path, window.location.origin);
1767
+ url.searchParams.set(param, query.data.code);
1768
+ link = url.toString();
1231
1769
  }
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 };
1770
+ return { ...query, link };
1771
+ }, [query, path, param]);
1240
1772
  }
1241
1773
 
1242
1774
  // src/react/leaderboards.ts
1243
- import { useCallback as useCallback7, useEffect as useEffect9, useState as useState10 } from "react";
1244
1775
  function useLeaderboard(board, options = {}) {
1245
1776
  const relay = useRelay();
1246
- const { member } = useExperience();
1247
1777
  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();
1265
- }
1266
- }, intervalMs);
1267
- return () => clearInterval(timer);
1268
- }, [refresh, member, intervalMs]);
1269
- return { ...state, refresh };
1778
+ return useRelayQuery(
1779
+ [...relayKeys.leaderboards, board, "standings", limit],
1780
+ (request) => relay.leaderboards.standings(board, { limit }, request),
1781
+ { intervalMs }
1782
+ );
1270
1783
  }
1271
1784
 
1272
1785
  // src/react/balances.ts
1273
- import { useCallback as useCallback8, useEffect as useEffect10, useRef as useRef5, useState as useState11 } from "react";
1274
- var signedOut = (cause) => cause instanceof RelayError && cause.status === 401;
1275
- var messageOf = (cause, fallback) => cause instanceof Error && cause.message ? cause.message : fallback;
1276
- function useCompletions() {
1277
- const { experience, member } = useExperience();
1278
- const mark = experience.status === "ready" ? completionMark([
1279
- ...experience.quests,
1280
- ...experience.collections.flatMap((c) => c.quests)
1281
- ]) : "";
1282
- return { member, mark };
1283
- }
1284
1786
  function useBalances() {
1285
1787
  const relay = useRelay();
1286
- const { member, mark } = useCompletions();
1287
- const [state, setState] = useState11({ status: "loading" });
1288
- const generation = useRef5(0);
1289
- const refresh = useCallback8(async () => {
1290
- const current = ++generation.current;
1291
- try {
1292
- const { items } = await relay.me.balances();
1293
- if (current === generation.current) {
1294
- setState({ status: "ready", items, stale: null });
1295
- }
1296
- } catch (cause) {
1297
- if (current !== generation.current) {
1298
- return;
1299
- }
1300
- if (signedOut(cause)) {
1301
- setState({ status: "signed-out" });
1302
- return;
1303
- }
1304
- const message = messageOf(cause, "Could not load your points.");
1305
- setState(
1306
- (previous) => previous.status === "ready" ? { ...previous, stale: message } : { status: "error", message }
1307
- );
1308
- }
1309
- }, [relay]);
1310
- const shown = useRef5(null);
1311
- useEffect10(() => {
1312
- if (shown.current !== null && shown.current !== member) {
1313
- setState({ status: "loading" });
1314
- }
1315
- shown.current = member;
1316
- void refresh();
1317
- }, [refresh, member, mark]);
1318
- return { ...state, refresh };
1788
+ return useMemberQuery(
1789
+ relayKeys.balances,
1790
+ (options) => relay.me.balances(options)
1791
+ );
1319
1792
  }
1320
1793
  function usePointHistory(ledger) {
1321
1794
  const relay = useRelay();
1322
- const { member, mark } = useCompletions();
1323
- const [state, setState] = useState11({ status: "loading" });
1324
- const shown = useRef5(null);
1325
- const cursor = useRef5(null);
1326
- const generation = useRef5(0);
1327
- const paging = useRef5({ ready: false, busy: false });
1328
- const load = useCallback8(
1329
- async (first) => {
1330
- if (first) {
1331
- generation.current++;
1332
- paging.current = { ready: false, busy: false };
1333
- } else {
1334
- if (!paging.current.ready || paging.current.busy || cursor.current === null) {
1335
- return;
1336
- }
1337
- paging.current.busy = true;
1338
- setState(
1339
- (previous) => previous.status === "ready" ? { ...previous, loading: true, error: null } : previous
1340
- );
1341
- }
1342
- const current = generation.current;
1343
- const own = paging.current;
1344
- try {
1345
- const page = await relay.me.pointHistory({
1346
- ledger,
1347
- ...!first && cursor.current ? { cursor: cursor.current } : {}
1348
- });
1349
- if (current !== generation.current) {
1350
- return;
1351
- }
1352
- cursor.current = page.nextCursor;
1353
- own.ready = true;
1354
- setState((previous) => ({
1355
- status: "ready",
1356
- items: !first && previous.status === "ready" ? [...previous.items, ...page.items] : page.items,
1357
- more: page.nextCursor !== null,
1358
- loading: false,
1359
- error: null
1360
- }));
1361
- } catch (cause) {
1362
- if (current !== generation.current) {
1363
- return;
1364
- }
1365
- if (first && signedOut(cause)) {
1366
- setState({ status: "signed-out" });
1367
- return;
1368
- }
1369
- const code = cause instanceof RelayError ? cause.code : void 0;
1370
- if (first && code === RelayErrorCode.PointLedgerNotFound) {
1371
- setState({ status: "missing" });
1372
- return;
1373
- }
1374
- if (first && code === RelayErrorCode.NotJoined) {
1375
- setState({ status: "not-joined" });
1376
- return;
1377
- }
1378
- const message = messageOf(cause, "Could not load your point history.");
1379
- setState(
1380
- (previous) => !first && previous.status === "ready" ? { ...previous, loading: false, error: message } : { status: "error", message }
1381
- );
1382
- } finally {
1383
- own.busy = false;
1384
- }
1385
- },
1386
- [relay, ledger]
1795
+ return useMemberPages(
1796
+ [...relayKeys.balances, "history", ledger],
1797
+ ({ cursor, ...options }) => relay.me.pointHistory(cursor ? { ledger, cursor } : { ledger }, options)
1387
1798
  );
1388
- useEffect10(() => {
1389
- const previous = shown.current;
1390
- if (previous && (previous.ledger !== ledger || previous.member !== null && previous.member !== member)) {
1391
- setState({ status: "loading" });
1392
- }
1393
- shown.current = { member, ledger };
1394
- cursor.current = null;
1395
- void load(true);
1396
- return () => {
1397
- generation.current++;
1398
- };
1399
- }, [load, ledger, member, mark]);
1400
- return {
1401
- ...state,
1402
- refresh: () => load(true),
1403
- /** Appends the next page; does nothing while a page loads or when there is none. */
1404
- loadMore: () => load(false)
1405
- };
1406
1799
  }
1407
1800
 
1408
1801
  // src/react/references.ts
1409
- import { useCallback as useCallback9, useEffect as useEffect11, useState as useState12 } from "react";
1410
- function useReferences(read, failure) {
1411
- const relay = useRelay();
1412
- const [state, setState] = useState12({ status: "loading" });
1413
- const refresh = useCallback9(async () => {
1414
- try {
1415
- setState({ status: "ready", items: await read(relay) });
1416
- } catch (cause) {
1417
- setState({
1418
- status: "error",
1419
- message: cause instanceof Error && cause.message ? cause.message : failure
1420
- });
1421
- }
1422
- }, [relay, read, failure]);
1423
- useEffect11(() => {
1424
- void refresh();
1425
- }, [refresh]);
1426
- return { ...state, refresh };
1427
- }
1428
- var readPointLedgers = (relay) => relay.pointLedgers.list();
1429
- var readTieredRewards = (relay) => relay.tieredRewards.list();
1430
1802
  function usePointLedgers() {
1431
- return useReferences(readPointLedgers, "Could not load point ledgers.");
1803
+ const relay = useRelay();
1804
+ return useRelayQuery(
1805
+ relayKeys.pointLedgers,
1806
+ (options) => relay.pointLedgers.list(options)
1807
+ );
1432
1808
  }
1433
1809
  function useTieredRewards() {
1434
- return useReferences(readTieredRewards, "Could not load tiered rewards.");
1810
+ const relay = useRelay();
1811
+ return useRelayQuery(
1812
+ relayKeys.tieredRewards,
1813
+ (options) => relay.tieredRewards.list(options)
1814
+ );
1435
1815
  }
1436
1816
  export {
1437
- PENDING_POLL_MS,
1438
- PROFILE_CHANGED,
1439
- RETRY_DELAYS_MS,
1440
1817
  RelayProvider,
1441
1818
  canRepeat,
1442
1819
  canSubmit,
1443
1820
  captureReferralCode,
1444
1821
  completionCountLabel,
1445
- completionMark,
1446
- hasPending,
1447
1822
  isAutomatic,
1448
1823
  isConnected,
1449
1824
  isFinished,
1450
1825
  progressOf,
1451
1826
  questGroups,
1452
1827
  questStatus,
1828
+ questStatusText,
1829
+ relayKeys,
1453
1830
  roleDeliveryLabel,
1454
- runsSilently,
1455
- sanitizeDeliveries,
1831
+ roleDeliveryStatus,
1456
1832
  useAccounts,
1457
1833
  useBalances,
1458
1834
  useExperience,
1835
+ useInvalidate,
1459
1836
  useInvitation,
1460
1837
  useLeaderboard,
1461
1838
  useLeaderboardPlacement,
1462
1839
  useMe,
1840
+ useMemberPages,
1841
+ useMemberQuery,
1463
1842
  useMyProfile,
1464
1843
  usePointHistory,
1465
1844
  usePointLedgers,
@@ -1469,6 +1848,9 @@ export {
1469
1848
  useQuest,
1470
1849
  useReferralSummary,
1471
1850
  useRelay,
1851
+ useRelayPages,
1852
+ useRelayQuery,
1853
+ useSession,
1472
1854
  useSignIn,
1473
1855
  useSignInMethods,
1474
1856
  useSignInOptions,
@@ -1476,7 +1858,5 @@ export {
1476
1858
  useSignOut,
1477
1859
  useTieredRewards,
1478
1860
  useWalletDiscovery,
1479
- useWallets,
1480
- visibleQuests,
1481
- watchRoleDeliveries
1861
+ useWallets
1482
1862
  };