@hadoku/prefs-client 1.1.4 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -121,12 +121,15 @@ watchparty character.
121
121
 
122
122
  ## Avatars (`@wolffm/prefs-client/avatar`)
123
123
 
124
- One resolved avatar per hadoku user, chosen by the user at `hadoku.me/account`.
124
+ One resolved avatar per hadoku user, chosen by the user in the fleet settings popout
125
+ (`ConnectedSettings`, @wolffm/task-ui-components): the picture's source and their character.
125
126
 
126
127
  ```ts
127
- type ResolvedAvatar =
128
- | { source: 'sprite'; sprite?: string } // watchparty character id; absent = your default
129
- | { source: 'discord' | 'steam' | 'upload'; url: string }; // square https image, >= 184px
128
+ interface ResolvedAvatar {
129
+ source: 'sprite' | 'discord' | 'steam' | 'upload'; // what the picture is
130
+ url?: string; // present exactly when source !== 'sprite': square https image, >= 184px
131
+ sprite?: string; // the person's character, WHATEVER the source; absent = your default
132
+ }
130
133
  ```
131
134
 
132
135
  Browser, the signed-in user's own avatar:
@@ -152,12 +155,13 @@ Rules for an app that renders avatars:
152
155
  - **`url` → `<img src>`**, nothing else: never fetch it server-side, never proxy it. Set
153
156
  `referrerpolicy="no-referrer"`. A URL can stop working (a changed Discord picture, an admin
154
157
  removal): on `error`, fall back to the sprite.
155
- - **`sprite`**: the app that owns the sprite art builds its URL (watchparty owns art revisions).
156
- Without a `sprite`, render your own default.
158
+ - **`sprite`** is the character even when the picture is a Discord avatar: a game uses it as the
159
+ piece and its colour. The app that owns the art builds its URL (watchparty owns art revisions;
160
+ its public `GET /watchparty/api/characters` lists them). Without one, use your own default.
157
161
  - **Steam attribution is required** wherever a `steam` avatar is shown (Steam Web API terms): a
158
162
  visible line such as "Steam avatars via the Steam Web API · not affiliated with Valve", linking
159
163
  to `https://store.steampowered.com/`. Do not use Steam's or Discord's logos, and do not imply
160
164
  either endorses the app.
161
165
  - Resolve on join and on reconnect; avatars change rarely. `resolveAvatars` batches past 100 ids.
162
- - Source choice, linking, uploads and removal all happen on `hadoku.me/account` — link there
163
- rather than building your own.
166
+ - Source choice, character choice, linking (`linkInPopup`: no page navigation), uploads and
167
+ removal all happen in the settings popout. Don't build your own.
package/dist/avatar.d.ts CHANGED
@@ -14,34 +14,48 @@ type LinkProvider = (typeof LINK_PROVIDERS)[number];
14
14
  /**
15
15
  * The one avatar every app renders for a user.
16
16
  *
17
- * - `sprite`: `sprite` is the watchparty character id from the user's
18
- * watchparty prefs, or absent when they never picked one (render your own
19
- * default). There is no `url`: the app that owns the sprite art owns its
20
- * URL and its revision.
21
- * - `discord` / `steam` / `upload`: `url` is an absolute https image URL,
22
- * square, at least 184px. Render it as an `<img>`; never fetch it server-side
23
- * on a user's behalf.
17
+ * - `source` is what the picture is. `'sprite'` means the character is the
18
+ * picture; any other source carries `url`.
19
+ * - `url` (present exactly when `source` is not `'sprite'`) is an absolute
20
+ * https image URL, square, at least 184px, on a host the hadoku.me CSP admits.
21
+ * Render it as an `<img>`; never fetch it server-side.
22
+ * - `sprite` is the person's character (a watchparty character id) whenever
23
+ * they have one, WHATEVER the source: games use it as the piece and the
24
+ * name colour even when the picture is a Discord avatar. Absent = never
25
+ * picked; use your own default. The app that owns the art (watchparty, its
26
+ * public GET /watchparty/api/characters) owns its URL and revision.
24
27
  *
25
- * A source that stops being available (an unlink, an admin removal, a Discord
26
- * avatar that changed) resolves to `sprite` with no action from the app.
28
+ * A source that stops being available (an unlink, an admin removal, an account
29
+ * whose custom avatar was removed) resolves to `sprite` with no action from the app.
27
30
  */
28
- type ResolvedAvatar = {
29
- source: 'sprite';
31
+ interface ResolvedAvatar {
32
+ source: AvatarSource;
33
+ url?: string;
30
34
  sprite?: string;
31
- url?: undefined;
35
+ }
36
+ /** PUT /api/v1/_avatar — either or both. */
37
+ interface AvatarUpdate {
38
+ source?: AvatarSource;
39
+ /** A watchparty character id (SPRITE_ID_PATTERN). */
40
+ sprite?: string;
41
+ }
42
+ /** What the callback page posts to the window that opened it (same origin only). */
43
+ declare const LINK_MESSAGE_TYPE = "hadoku-avatar-link";
44
+ type LinkMessage = {
45
+ type: typeof LINK_MESSAGE_TYPE;
46
+ ok: true;
47
+ provider: LinkProvider;
32
48
  } | {
33
- source: 'discord' | 'steam' | 'upload';
34
- url: string;
35
- sprite?: undefined;
49
+ type: typeof LINK_MESSAGE_TYPE;
50
+ ok: false;
51
+ error: string;
36
52
  };
37
53
  /** A linked account as its owner sees it. Never carries a token. */
38
54
  interface LinkedAccountView {
39
- /** null when the account has no custom avatar, or the cached one went stale. */
55
+ /** null when the account has no custom avatar. */
40
56
  avatarUrl: string | null;
41
57
  linkedAt: number;
42
58
  refreshedAt: number;
43
- /** Set when Discord stopped serving the cached avatar: link again to pick up the new one. */
44
- stale: boolean;
45
59
  }
46
60
  interface UploadView {
47
61
  url: string;
@@ -94,6 +108,8 @@ declare const SPRITE_ID_PATTERN: RegExp;
94
108
  */
95
109
  declare const LINK_CALLBACK_PATH = "/account/link/callback";
96
110
 
111
+ /** sessionStorage key the callback page reads to know it runs in the link popup. */
112
+ declare const LINK_POPUP_MARK = "hadoku-avatar-link-popup";
97
113
  declare class AvatarError extends Error {
98
114
  readonly status: number;
99
115
  constructor(status: number, message: string);
@@ -108,6 +124,8 @@ declare function createAvatarClient(opts?: AvatarClientOptions): {
108
124
  get: () => Promise<AvatarSettings>;
109
125
  /** Pick a source. 409 when it is not available (not linked, nothing uploaded). */
110
126
  choose: (source: AvatarSource) => Promise<AvatarSettings>;
127
+ /** Set the source, the character, or both in one write. */
128
+ update: (u: AvatarUpdate) => Promise<AvatarSettings>;
111
129
  /**
112
130
  * Begin linking. Navigate the browser to the returned URL; the provider
113
131
  * returns it to hadoku.me/account/link/callback, which completes the link.
@@ -115,6 +133,15 @@ declare function createAvatarClient(opts?: AvatarClientOptions): {
115
133
  * Discord or Steam before they ask.
116
134
  */
117
135
  startLink: (provider: LinkProvider) => Promise<LinkStart>;
136
+ /**
137
+ * Link in a popup, so the page never navigates. Call it straight from the
138
+ * click handler: the window is opened before the first await, which is
139
+ * what popup blockers allow. Resolves with fresh settings once the popup
140
+ * reports back or is closed (closing is not an error: the settings say
141
+ * whether the link landed). Rejects only when the popup could not open
142
+ * or the link could not start.
143
+ */
144
+ linkInPopup: (provider: LinkProvider) => Promise<AvatarSettings>;
118
145
  /** Finish a link from the callback page's query string (`location.search`). */
119
146
  completeLink: (query: string) => Promise<AvatarSettings>;
120
147
  /** Delete the linked id and its cached avatar URL. */
@@ -134,4 +161,4 @@ interface ResolveAvatarsOptions extends AvatarClientOptions {
134
161
  */
135
162
  declare function resolveAvatars(opts: ResolveAvatarsOptions, userIds: readonly string[]): Promise<Record<string, ResolvedAvatar>>;
136
163
 
137
- export { AVATAR_SOURCES, type AvatarClient, type AvatarClientOptions, AvatarError, type AvatarSettings, type AvatarSource, type AvatarUploadLimits, LINK_CALLBACK_PATH, LINK_PROVIDERS, type LinkProvider, type LinkStart, type LinkedAccountView, RESOLVE_MAX_IDS, type ResolveAvatarsOptions, type ResolveRequest, type ResolveResponse, type ResolvedAvatar, SPRITE_ID_PATTERN, type UploadView, createAvatarClient, resolveAvatars };
164
+ export { AVATAR_SOURCES, type AvatarClient, type AvatarClientOptions, AvatarError, type AvatarSettings, type AvatarSource, type AvatarUpdate, type AvatarUploadLimits, LINK_CALLBACK_PATH, LINK_MESSAGE_TYPE, LINK_POPUP_MARK, LINK_PROVIDERS, type LinkMessage, type LinkProvider, type LinkStart, type LinkedAccountView, RESOLVE_MAX_IDS, type ResolveAvatarsOptions, type ResolveRequest, type ResolveResponse, type ResolvedAvatar, SPRITE_ID_PATTERN, type UploadView, createAvatarClient, resolveAvatars };
package/dist/avatar.js CHANGED
@@ -4,11 +4,21 @@ var PREFS_API_BASE = "https://hadoku.me/prefs";
4
4
  // src/avatar-contract.ts
5
5
  var AVATAR_SOURCES = ["sprite", "discord", "steam", "upload"];
6
6
  var LINK_PROVIDERS = ["discord", "steam"];
7
+ var LINK_MESSAGE_TYPE = "hadoku-avatar-link";
7
8
  var RESOLVE_MAX_IDS = 100;
8
9
  var SPRITE_ID_PATTERN = /^[a-z][a-z0-9-]{0,31}$/;
9
10
  var LINK_CALLBACK_PATH = "/account/link/callback";
10
11
 
11
12
  // src/avatar.ts
13
+ var LINK_WINDOW_NAME = "hadoku-avatar-link";
14
+ var LINK_POPUP_MARK = "hadoku-avatar-link-popup";
15
+ function markPopup(on) {
16
+ try {
17
+ if (on) sessionStorage.setItem(LINK_POPUP_MARK, "1");
18
+ else sessionStorage.removeItem(LINK_POPUP_MARK);
19
+ } catch {
20
+ }
21
+ }
12
22
  var AvatarError = class extends Error {
13
23
  constructor(status, message) {
14
24
  super(message);
@@ -32,11 +42,52 @@ function createAvatarClient(opts = {}) {
32
42
  method,
33
43
  ...body === void 0 ? {} : { headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }
34
44
  });
45
+ const getSettings = () => call("");
46
+ async function linkInPopup(provider) {
47
+ markPopup(true);
48
+ const popup = window.open("about:blank", LINK_WINDOW_NAME, "popup,width=520,height=760");
49
+ markPopup(false);
50
+ if (!popup)
51
+ throw new AvatarError(0, "The sign-in window was blocked: allow pop-ups for hadoku.me");
52
+ let authorizeUrl;
53
+ try {
54
+ ({ authorizeUrl } = await send(`/link/${provider}`, "POST", {}));
55
+ } catch (err) {
56
+ popup.close();
57
+ throw err;
58
+ }
59
+ popup.location.href = authorizeUrl;
60
+ return new Promise((resolve, reject) => {
61
+ const channel = new BroadcastChannel(LINK_MESSAGE_TYPE);
62
+ let settled = false;
63
+ const finish = (failure) => {
64
+ if (settled) return;
65
+ settled = true;
66
+ channel.close();
67
+ clearInterval(closedPoll);
68
+ clearTimeout(giveUp);
69
+ if (failure) reject(new AvatarError(400, failure));
70
+ else getSettings().then(resolve, reject);
71
+ };
72
+ channel.onmessage = (e) => {
73
+ if (e.data?.type !== LINK_MESSAGE_TYPE) return;
74
+ finish(e.data.ok ? void 0 : e.data.error);
75
+ };
76
+ const closedPoll = setInterval(() => {
77
+ if (!popup.closed) return;
78
+ clearInterval(closedPoll);
79
+ setTimeout(() => finish(), 1500);
80
+ }, 500);
81
+ const giveUp = setTimeout(() => finish(), 10 * 60 * 1e3);
82
+ });
83
+ }
35
84
  return {
36
85
  /** The caller's settings, including the resolved avatar. */
37
- get: () => call(""),
86
+ get: getSettings,
38
87
  /** Pick a source. 409 when it is not available (not linked, nothing uploaded). */
39
88
  choose: (source) => send("", "PUT", { source }),
89
+ /** Set the source, the character, or both in one write. */
90
+ update: (u) => send("", "PUT", u),
40
91
  /**
41
92
  * Begin linking. Navigate the browser to the returned URL; the provider
42
93
  * returns it to hadoku.me/account/link/callback, which completes the link.
@@ -44,6 +95,15 @@ function createAvatarClient(opts = {}) {
44
95
  * Discord or Steam before they ask.
45
96
  */
46
97
  startLink: (provider) => send(`/link/${provider}`, "POST", {}),
98
+ /**
99
+ * Link in a popup, so the page never navigates. Call it straight from the
100
+ * click handler: the window is opened before the first await, which is
101
+ * what popup blockers allow. Resolves with fresh settings once the popup
102
+ * reports back or is closed (closing is not an error: the settings say
103
+ * whether the link landed). Rejects only when the popup could not open
104
+ * or the link could not start.
105
+ */
106
+ linkInPopup: (provider) => linkInPopup(provider),
47
107
  /** Finish a link from the callback page's query string (`location.search`). */
48
108
  completeLink: (query) => send("/link/complete", "POST", { query: query.replace(/^\?/, "") }),
49
109
  /** Delete the linked id and its cached avatar URL. */
@@ -72,6 +132,8 @@ export {
72
132
  AVATAR_SOURCES,
73
133
  AvatarError,
74
134
  LINK_CALLBACK_PATH,
135
+ LINK_MESSAGE_TYPE,
136
+ LINK_POPUP_MARK,
75
137
  LINK_PROVIDERS,
76
138
  RESOLVE_MAX_IDS,
77
139
  SPRITE_ID_PATTERN,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hadoku/prefs-client",
3
- "version": "1.1.4",
3
+ "version": "1.1.6",
4
4
  "description": "Client SDK for the hadoku unified preferences store. Wraps /prefs/api/v1/* with localStorage caching, debounced writes, Zod-validated reads, and a usePrefs() React hook, plus match history, ratings and leaderboards for every game (./games), and the one resolved avatar per user (./avatar).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",