@hadoku/task-ui-components 4.23.5 → 4.23.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/components/AvatarBuilder.d.ts +28 -0
  2. package/dist/components/AvatarBuilder.d.ts.map +1 -0
  3. package/dist/components/AvatarBuilder.js +731 -0
  4. package/dist/components/AvatarBuilder.js.map +1 -0
  5. package/dist/components/AvatarDialog.d.ts +6 -4
  6. package/dist/components/AvatarDialog.d.ts.map +1 -1
  7. package/dist/components/AvatarDialog.js +3 -3
  8. package/dist/components/AvatarDialog.js.map +1 -1
  9. package/dist/components/AvatarPicker.d.ts +21 -18
  10. package/dist/components/AvatarPicker.d.ts.map +1 -1
  11. package/dist/components/AvatarPicker.js +17 -145
  12. package/dist/components/AvatarPicker.js.map +1 -1
  13. package/dist/components/ConnectedSettings.d.ts.map +1 -1
  14. package/dist/components/ConnectedSettings.js +20 -1
  15. package/dist/components/ConnectedSettings.js.map +1 -1
  16. package/dist/components/LinkedAccounts.d.ts +21 -0
  17. package/dist/components/LinkedAccounts.d.ts.map +1 -0
  18. package/dist/components/LinkedAccounts.js +119 -0
  19. package/dist/components/LinkedAccounts.js.map +1 -0
  20. package/dist/lib/avatarData.d.ts +27 -0
  21. package/dist/lib/avatarData.d.ts.map +1 -0
  22. package/dist/lib/avatarData.js +23 -0
  23. package/dist/lib/avatarData.js.map +1 -0
  24. package/dist/vendor/wolffm/prefs-client/avatar.js +7 -1
  25. package/dist/vendor/wolffm/prefs-client/avatar.js.map +1 -1
  26. package/dist/vendor/wolffm/prefs-client/cosmetics.js +491 -0
  27. package/dist/vendor/wolffm/prefs-client/cosmetics.js.map +1 -0
  28. package/package.json +2 -2
  29. package/src/components/AvatarBuilder.tsx +901 -0
  30. package/src/components/AvatarDialog.tsx +8 -6
  31. package/src/components/AvatarPicker.tsx +45 -228
  32. package/src/components/ConnectedSettings.tsx +26 -0
  33. package/src/components/LinkedAccounts.tsx +176 -0
  34. package/src/lib/avatarData.ts +40 -0
@@ -1,7 +1,7 @@
1
1
  /**
2
- * AvatarDialog — the avatar picker in its own dialog, opened from the settings
3
- * popout's "Change". The popout is 300px wide; the picker (and the cosmetics
4
- * that will join it) needs the room.
2
+ * AvatarDialog — the avatar builder (picture, character and cosmetics, with a
3
+ * live preview) in its own dialog, opened from the settings popout's "Change".
4
+ * The popout is 300px wide; the builder needs the room.
5
5
  *
6
6
  * Rendered into document.body, so no ancestor of the popout (a transform, an
7
7
  * overflow, a stacking context) can clip or trap it. The popout stays open
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import React, { useEffect, useId, useRef } from 'react'
15
15
  import { createPortal } from 'react-dom'
16
- import { AvatarPicker } from './AvatarPicker'
16
+ import { AvatarBuilder } from './AvatarBuilder'
17
17
 
18
18
  const S: Record<string, React.CSSProperties> = {
19
19
  overlay: {
@@ -55,9 +55,11 @@ export interface AvatarDialogProps {
55
55
  onClose: () => void
56
56
  /** Where focus goes when the dialog closes (the Change button). */
57
57
  returnFocus?: React.RefObject<HTMLElement | null>
58
+ /** The person's display name, shown in the preview. */
59
+ name?: string | null
58
60
  }
59
61
 
60
- export function AvatarDialog({ open, onClose, returnFocus }: AvatarDialogProps) {
62
+ export function AvatarDialog({ open, onClose, returnFocus, name = null }: AvatarDialogProps) {
61
63
  const titleId = useId()
62
64
  const cardRef = useRef<HTMLDivElement>(null)
63
65
  const onCloseRef = useRef(onClose)
@@ -108,7 +110,7 @@ export function AvatarDialog({ open, onClose, returnFocus }: AvatarDialogProps)
108
110
  Done
109
111
  </button>
110
112
  </div>
111
- <AvatarPicker />
113
+ <AvatarBuilder name={name} />
112
114
  </div>
113
115
  </div>,
114
116
  document.body
@@ -1,56 +1,26 @@
1
1
  /**
2
- * AvatarPicker — the body of AvatarDialog (opened from ConnectedSettings): the picture people
3
- * see (character, Discord, Steam or an upload) and the character games use as
4
- * the piece. One resolved avatar per person, stored in hadoku_site's prefs-api
5
- * and rendered by every app (`@wolffm/prefs-client/avatar`, bundled into this
6
- * package at build so the link protocol cannot drift from the callback page).
2
+ * AvatarPicker — the Picture tab of AvatarBuilder (inside AvatarDialog, opened
3
+ * from ConnectedSettings): the picture people see (character, Discord, Steam or
4
+ * an upload) and the character games use as the piece. One resolved avatar per
5
+ * person, stored in hadoku_site's prefs-api and rendered by every app
6
+ * (`@wolffm/prefs-client/avatar`, bundled into this package at build so the link
7
+ * protocol cannot drift from the callback page).
7
8
  *
8
9
  * Everything happens in place: picking, uploading and removing are requests
9
- * from here, and linking Discord or Steam signs in through a popup
10
- * (`linkInPopup`), so the page under the popout never navigates and a game
11
- * keeps running.
10
+ * from here. Linking Discord or Steam lives in the popout's Account tab
11
+ * (LinkedAccounts); a linked account's picture is a choice here.
12
12
  *
13
- * Loaded only when the person opens the dialog ("Change"), never at mount: the avatar
14
- * read can refresh a linked picture from Discord or Steam, which no page load
15
- * should cost, and opening the gear itself must stay free (settings-prefetch
16
- * e2e).
17
- *
18
- * COMPLIANCE (Steam Web API terms; Discord developer terms), carried over from
19
- * hadoku.me/account: nothing is fetched from a provider until the person reads
20
- * what is read and kept and presses Continue; Steam attribution and a privacy
21
- * link sit under the controls; no provider logos, and no implied endorsement.
13
+ * AvatarBuilder owns the settings and the character table and loads them only
14
+ * when the dialog opens; this component draws them and sends changes through
15
+ * `run`, which applies the answer everywhere in the dialog.
22
16
  *
23
17
  * Styles are inline for the reason PASSKEY_DOT's are (ConnectedSettings.tsx):
24
18
  * apps compile app-header.css at their own build, so a new rule there reaches
25
19
  * an app only when it rebuilds.
26
20
  */
27
- import React, { useCallback, useEffect, useState } from 'react'
28
- import {
29
- createAvatarClient,
30
- AvatarError,
31
- type AvatarSettings,
32
- type AvatarSource,
33
- type LinkProvider,
34
- type ResolvedAvatar
35
- } from '@wolffm/prefs-client/avatar'
36
- import { AVATAR_EVENT } from '../lib/avatarEvent'
37
-
38
- const client = createAvatarClient({ baseUrl: '/prefs' })
39
-
40
- /** watchparty's public character table: GET /watchparty/api/characters. */
41
- const CHARACTERS_URL = '/watchparty/api/characters'
42
- const WATCHPARTY_BASE = '/watchparty'
43
-
44
- interface Character {
45
- id: string
46
- name: string
47
- color: string
48
- portrait: string
49
- }
50
- interface CharacterSet {
51
- id: string
52
- characters: Character[]
53
- }
21
+ import React, { useEffect, useState } from 'react'
22
+ import { type AvatarSettings, type AvatarSource } from '@wolffm/prefs-client/avatar'
23
+ import { avatarClient, portraitUrl, type CharacterSet } from '../lib/avatarData'
54
24
 
55
25
  const LABEL: Record<AvatarSource, string> = {
56
26
  sprite: 'Character',
@@ -59,39 +29,8 @@ const LABEL: Record<AvatarSource, string> = {
59
29
  upload: 'Upload'
60
30
  }
61
31
 
62
- const CONSENT: Record<LinkProvider, { points: string[]; go: string }> = {
63
- discord: {
64
- points: [
65
- 'Discord asks you to share your basic profile ("identify").',
66
- 'Kept: your Discord user ID and the address of your profile picture, refreshed while linked.',
67
- 'Not kept: your username, email, servers or any token.',
68
- 'Unlink deletes both.'
69
- ],
70
- go: 'Continue to Discord'
71
- },
72
- steam: {
73
- points: [
74
- 'You sign in on steamcommunity.com; your password never reaches hadoku.me.',
75
- 'Your public Steam profile picture is then read through the Steam Web API, now and periodically while linked.',
76
- 'Kept: your Steam ID and the address of that picture.',
77
- 'Unlink deletes both and stops the reads.'
78
- ],
79
- go: 'Continue to Steam'
80
- }
81
- }
82
-
83
32
  const S = {
84
33
  stack: { display: 'flex', flexDirection: 'column', gap: 10, width: '100%' },
85
- current: { display: 'flex', alignItems: 'center', gap: 10 },
86
- preview: {
87
- width: 64,
88
- height: 64,
89
- borderRadius: '50%',
90
- overflow: 'hidden',
91
- flex: 'none',
92
- background: 'var(--color-bg-alt)',
93
- border: '1px solid var(--color-border)'
94
- },
95
34
  img: { width: '100%', height: '100%', objectFit: 'cover', display: 'block' },
96
35
  sources: { display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 6 },
97
36
  source: {
@@ -130,14 +69,9 @@ const S = {
130
69
  cursor: 'pointer'
131
70
  },
132
71
  line: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },
133
- grow: { flex: 1, minWidth: 0 },
134
- list: { margin: 0, paddingLeft: 18, fontSize: '0.75rem', lineHeight: 1.45 }
72
+ grow: { flex: 1, minWidth: 0 }
135
73
  } satisfies Record<string, React.CSSProperties>
136
74
 
137
- function portraitUrl(c: Character): string {
138
- return c.portrait.startsWith('/') ? `${WATCHPARTY_BASE}${c.portrait}` : c.portrait
139
- }
140
-
141
75
  function Picture({ src, style }: { src: string | null; style: React.CSSProperties }) {
142
76
  const [failed, setFailed] = useState(false)
143
77
  useEffect(() => setFailed(false), [src])
@@ -156,63 +90,29 @@ function Picture({ src, style }: { src: string | null; style: React.CSSPropertie
156
90
  )
157
91
  }
158
92
 
159
- export function AvatarPicker() {
160
- const [settings, setSettings] = useState<AvatarSettings | null>(null)
161
- const [sets, setSets] = useState<CharacterSet[] | null>(null)
162
- const [setIdx, setSetIdx] = useState(0)
163
- const [busy, setBusy] = useState(false)
164
- const [error, setError] = useState<string | null>(null)
165
- const [consent, setConsent] = useState<LinkProvider | null>(null)
93
+ export interface AvatarPickerProps {
94
+ settings: AvatarSettings
95
+ sets: CharacterSet[] | null
96
+ busy: boolean
97
+ /** Send a change; the builder applies the answer and reports failures. */
98
+ run: (work: () => Promise<AvatarSettings>) => Promise<void>
99
+ }
166
100
 
101
+ export function AvatarPicker({ settings, sets, busy, run }: AvatarPickerProps) {
102
+ const [setIdx, setSetIdx] = useState(0)
103
+ // Open on the set holding the person's character.
167
104
  useEffect(() => {
168
- let live = true
169
- client
170
- .get()
171
- .then(s => live && setSettings(s))
172
- .catch((err: unknown) => live && setError(message(err)))
173
- fetch(CHARACTERS_URL)
174
- .then(r =>
175
- r.ok
176
- ? (r.json() as Promise<{ sets: CharacterSet[] }>)
177
- : Promise.reject(new Error(`characters ${r.status}`))
178
- )
179
- .then(d => live && setSets(d.sets))
180
- .catch((err: unknown) => live && setError(message(err)))
181
- return () => {
182
- live = false
183
- }
184
- }, [])
185
-
186
- const run = useCallback(async (work: () => Promise<AvatarSettings>) => {
187
- setBusy(true)
188
- setError(null)
189
- try {
190
- const next = await work()
191
- setSettings(next)
192
- // The page under the popout (a watchparty room, a game) re-reads the
193
- // avatar from this instead of waiting for its next join.
194
- window.dispatchEvent(new window.CustomEvent<AvatarSettings>(AVATAR_EVENT, { detail: next }))
195
- } catch (err) {
196
- setError(message(err))
197
- } finally {
198
- setBusy(false)
199
- }
200
- }, [])
105
+ const at = sets?.findIndex(s => s.characters.some(c => c.id === settings.sprite)) ?? -1
106
+ if (at > 0) setSetIdx(at)
107
+ // Only when the table arrives: afterwards the person moves between sets.
108
+ // eslint-disable-next-line react-hooks/exhaustive-deps
109
+ }, [sets])
201
110
 
202
111
  const characters = new Map((sets ?? []).flatMap(s => s.characters).map(c => [c.id, c]))
203
112
  const spriteSrc = (id: string | null | undefined) => {
204
113
  const c = id ? characters.get(id) : undefined
205
114
  return c ? portraitUrl(c) : null
206
115
  }
207
- const srcOf = (a: ResolvedAvatar) => a.url ?? spriteSrc(a.sprite)
208
-
209
- if (!settings) {
210
- return (
211
- <span className="settings-popout__hint" role={error ? 'alert' : undefined}>
212
- {error ?? '…'}
213
- </span>
214
- )
215
- }
216
116
 
217
117
  const available: Record<AvatarSource, boolean> = {
218
118
  sprite: true,
@@ -229,28 +129,13 @@ export function AvatarPicker() {
229
129
  const shown = settings.avatar
230
130
  const current = sets?.[setIdx]
231
131
 
232
- // Must stay synchronous up to linkInPopup: the popup opens before its first
233
- // await, inside this click, which is what popup blockers allow.
234
- const link = (provider: LinkProvider) => {
235
- setConsent(null)
236
- run(async () => {
237
- const next = await client.linkInPopup(provider)
238
- // Linking is choosing, when the account came back with a picture.
239
- return next.linked[provider]?.avatarUrl ? client.choose(provider) : next
240
- }).catch(reportErr)
241
- }
242
-
243
132
  return (
244
133
  <div style={S.stack}>
245
- <div style={S.current}>
246
- <Picture src={srcOf(shown)} style={S.preview} />
247
- <span className="settings-popout__value">
248
- {LABEL[shown.source]}
249
- {settings.choice && settings.choice !== shown.source
250
- ? ` (${LABEL[settings.choice]} unavailable)`
251
- : ''}
134
+ {settings.choice && settings.choice !== shown.source && (
135
+ <span className="settings-popout__hint">
136
+ {LABEL[settings.choice]} is unavailable, so your character shows instead.
252
137
  </span>
253
- </div>
138
+ )}
254
139
 
255
140
  <div style={S.sources} role="radiogroup" aria-label="Picture">
256
141
  {(Object.keys(LABEL) as AvatarSource[]).map(src => (
@@ -260,14 +145,20 @@ export function AvatarPicker() {
260
145
  role="radio"
261
146
  aria-checked={shown.source === src}
262
147
  disabled={busy || !available[src]}
263
- title={available[src] ? undefined : 'Link or upload one first'}
148
+ title={
149
+ available[src]
150
+ ? undefined
151
+ : src === 'upload'
152
+ ? 'Upload a picture below first'
153
+ : `Link ${LABEL[src]} under Settings › Account first`
154
+ }
264
155
  style={{
265
156
  ...S.source,
266
157
  ...(shown.source === src ? S.sourceOn : {}),
267
158
  opacity: available[src] ? 1 : 0.5
268
159
  }}
269
160
  onClick={() => {
270
- run(() => client.choose(src)).catch(reportErr)
161
+ run(() => avatarClient.choose(src)).catch(reportErr)
271
162
  }}
272
163
  >
273
164
  <Picture src={thumbOf[src]} style={S.thumb} />
@@ -308,7 +199,7 @@ export function AvatarPicker() {
308
199
  borderColor: settings.sprite === c.id ? c.color : 'transparent'
309
200
  }}
310
201
  onClick={() => {
311
- run(() => client.update({ sprite: c.id })).catch(reportErr)
202
+ run(() => avatarClient.update({ sprite: c.id })).catch(reportErr)
312
203
  }}
313
204
  >
314
205
  <img src={portraitUrl(c)} alt="" style={S.img} />
@@ -320,69 +211,6 @@ export function AvatarPicker() {
320
211
  <span className="settings-popout__hint">…</span>
321
212
  ))}
322
213
 
323
- {(['discord', 'steam'] as const).map(p => {
324
- const linked = settings.linked[p]
325
- return (
326
- <div key={p} style={S.line}>
327
- <span className="settings-popout__label" style={S.grow}>
328
- {LABEL[p]}
329
- {!settings.providers[p]
330
- ? ' · not available yet'
331
- : linked
332
- ? linked.avatarUrl
333
- ? ' · linked'
334
- : ' · linked, no picture'
335
- : ''}
336
- </span>
337
- {settings.providers[p] && !linked && (
338
- <button
339
- type="button"
340
- className="settings-popout__btn"
341
- disabled={busy}
342
- onClick={() => setConsent(consent === p ? null : p)}
343
- >
344
- Link
345
- </button>
346
- )}
347
- {linked && (
348
- <button
349
- type="button"
350
- className="settings-popout__btn"
351
- disabled={busy}
352
- onClick={() => {
353
- run(() => client.unlink(p)).catch(reportErr)
354
- }}
355
- >
356
- Unlink
357
- </button>
358
- )}
359
- </div>
360
- )
361
- })}
362
-
363
- {consent && (
364
- <div style={S.stack}>
365
- <ul style={S.list}>
366
- {CONSENT[consent].points.map(t => (
367
- <li key={t}>{t}</li>
368
- ))}
369
- </ul>
370
- <span style={S.line}>
371
- <button
372
- type="button"
373
- className="settings-popout__btn settings-popout__btn--primary"
374
- disabled={busy}
375
- onClick={() => link(consent)}
376
- >
377
- {CONSENT[consent].go}
378
- </button>
379
- <button type="button" className="settings-popout__btn" onClick={() => setConsent(null)}>
380
- Cancel
381
- </button>
382
- </span>
383
- </div>
384
- )}
385
-
386
214
  <div style={S.line}>
387
215
  <span className="settings-popout__label" style={S.grow}>
388
216
  Upload
@@ -397,7 +225,7 @@ export function AvatarPicker() {
397
225
  onChange={e => {
398
226
  const file = e.currentTarget.files?.[0]
399
227
  e.currentTarget.value = ''
400
- if (file) run(() => client.upload(file)).catch(reportErr)
228
+ if (file) run(() => avatarClient.upload(file)).catch(reportErr)
401
229
  }}
402
230
  />
403
231
  </label>
@@ -407,28 +235,17 @@ export function AvatarPicker() {
407
235
  className="settings-popout__btn"
408
236
  disabled={busy}
409
237
  onClick={() => {
410
- run(() => client.removeUpload()).catch(reportErr)
238
+ run(() => avatarClient.removeUpload()).catch(reportErr)
411
239
  }}
412
240
  >
413
241
  Remove
414
242
  </button>
415
243
  )}
416
244
  </div>
417
-
418
- {error && (
419
- <span className="settings-popout__error" role="alert">
420
- {error}
421
- </span>
422
- )}
423
245
  </div>
424
246
  )
425
247
  }
426
248
 
427
- function message(err: unknown): string {
428
- if (err instanceof AvatarError && err.status === 401) return 'Sign in to set an avatar.'
429
- return err instanceof Error ? err.message : String(err)
430
- }
431
-
432
249
  function reportErr(err: unknown) {
433
250
  console.error('[AvatarPicker] action failed', (err as Error)?.message ?? err)
434
251
  }
@@ -33,6 +33,7 @@ import React, { useCallback, useEffect, useId, useRef, useState } from 'react'
33
33
  import { useSettingsTab } from '@wolffm/themes'
34
34
  import { SettingsIcon } from './ThemeIcons'
35
35
  import { AvatarDialog } from './AvatarDialog'
36
+ import { LinkedAccounts } from './LinkedAccounts'
36
37
 
37
38
  // A grantee's own service credentials, with a QR encoder for TOTP enrolment:
38
39
  // its own chunk, fetched only when a grantee opens it.
@@ -267,6 +268,8 @@ export function ConnectedSettings({
267
268
 
268
269
  // Avatar: the dialog's picker loads only when opened, never at mount (AvatarPicker).
269
270
  const [avatarOpen, setAvatarOpen] = useState(false)
271
+ // Linked accounts: loaded only when the row is opened (LinkedAccounts).
272
+ const [linksOpen, setLinksOpen] = useState(false)
270
273
  const avatarBtn = useRef<HTMLButtonElement>(null)
271
274
  // Service identities this person may mint for (whoami.serviceGrants).
272
275
  const [serviceGrants, setServiceGrants] = useState(0)
@@ -734,6 +737,7 @@ export function ConnectedSettings({
734
737
  open={avatarOpen}
735
738
  onClose={() => setAvatarOpen(false)}
736
739
  returnFocus={avatarBtn}
740
+ name={displayName}
737
741
  />
738
742
  </section>
739
743
  )}
@@ -951,6 +955,28 @@ export function ConnectedSettings({
951
955
  </section>
952
956
  )}
953
957
 
958
+ {/* Discord and Steam: linked here, so their pictures can be chosen in
959
+ the avatar dialog. Collapsed until Open: the read can refresh a
960
+ linked picture, which opening the gear must not cost. */}
961
+ {!resolving && userType !== 'public' && (
962
+ <section className="settings-popout__row settings-popout__row--stack">
963
+ <span className="settings-popout__inline">
964
+ <span className="settings-popout__label settings-popout__label--grow">
965
+ Linked accounts
966
+ </span>
967
+ <button
968
+ type="button"
969
+ className="settings-popout__btn"
970
+ aria-expanded={linksOpen}
971
+ onClick={() => setLinksOpen(v => !v)}
972
+ >
973
+ {linksOpen ? 'Done' : 'Open'}
974
+ </button>
975
+ </span>
976
+ {linksOpen && <LinkedAccounts />}
977
+ </section>
978
+ )}
979
+
954
980
  {/* A self-made account (member) can delete itself. The confirm is a
955
981
  separate button on its own line, so a double click on Delete
956
982
  never confirms. */}
@@ -0,0 +1,176 @@
1
+ /**
2
+ * LinkedAccounts — Discord and Steam, linked and unlinked from the settings
3
+ * popout's Account tab (operator, 2026-11-02: linking is an account matter, not
4
+ * part of changing the avatar). A linked account's picture then shows up as a
5
+ * choice in the avatar dialog's Picture tab.
6
+ *
7
+ * Loaded only when the person opens the row, never at mount: the read can
8
+ * refresh a linked picture from Discord or Steam, which opening the gear must
9
+ * not cost (settings-prefetch e2e).
10
+ *
11
+ * Linking signs in through a popup (`linkInPopup`), so the page under the
12
+ * popout never navigates and a game keeps running.
13
+ *
14
+ * COMPLIANCE (Steam Web API terms; Discord developer terms), carried over from
15
+ * hadoku.me/account: nothing is fetched from a provider until the person reads
16
+ * what is read and kept and presses Continue; no provider logos, and no implied
17
+ * endorsement.
18
+ */
19
+ import React, { useEffect, useState } from 'react'
20
+ import type { AvatarSettings, LinkProvider } from '@wolffm/prefs-client/avatar'
21
+ import { AVATAR_EVENT } from '../lib/avatarEvent'
22
+ import { avatarClient, avatarErrorText } from '../lib/avatarData'
23
+
24
+ const LABEL: Record<LinkProvider, string> = { discord: 'Discord', steam: 'Steam' }
25
+
26
+ const CONSENT: Record<LinkProvider, { points: string[]; go: string }> = {
27
+ discord: {
28
+ points: [
29
+ 'Discord asks you to share your basic profile ("identify").',
30
+ 'Kept: your Discord user ID and the address of your profile picture, refreshed while linked.',
31
+ 'Not kept: your username, email, servers or any token.',
32
+ 'Unlink deletes both.'
33
+ ],
34
+ go: 'Continue to Discord'
35
+ },
36
+ steam: {
37
+ points: [
38
+ 'You sign in on steamcommunity.com; your password never reaches hadoku.me.',
39
+ 'Your public Steam profile picture is then read through the Steam Web API, now and periodically while linked.',
40
+ 'Kept: your Steam ID and the address of that picture.',
41
+ 'Unlink deletes both and stops the reads.'
42
+ ],
43
+ go: 'Continue to Steam'
44
+ }
45
+ }
46
+
47
+ const S = {
48
+ stack: { display: 'flex', flexDirection: 'column', gap: 8, width: '100%' },
49
+ line: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },
50
+ grow: { flex: 1, minWidth: 0 },
51
+ list: { margin: 0, paddingLeft: 18, fontSize: '0.75rem', lineHeight: 1.45 }
52
+ } satisfies Record<string, React.CSSProperties>
53
+
54
+ export function LinkedAccounts() {
55
+ const [settings, setSettings] = useState<AvatarSettings | null>(null)
56
+ const [busy, setBusy] = useState(false)
57
+ const [error, setError] = useState<string | null>(null)
58
+ const [consent, setConsent] = useState<LinkProvider | null>(null)
59
+
60
+ useEffect(() => {
61
+ let live = true
62
+ avatarClient
63
+ .get()
64
+ .then(s => live && setSettings(s))
65
+ .catch((err: unknown) => live && setError(avatarErrorText(err)))
66
+ return () => {
67
+ live = false
68
+ }
69
+ }, [])
70
+
71
+ const run = async (work: () => Promise<AvatarSettings>) => {
72
+ setBusy(true)
73
+ setError(null)
74
+ try {
75
+ const next = await work()
76
+ setSettings(next)
77
+ // The page under the popout (a watchparty room, a game) re-reads the
78
+ // avatar from this, as it does after a change in the avatar dialog.
79
+ window.dispatchEvent(new window.CustomEvent<AvatarSettings>(AVATAR_EVENT, { detail: next }))
80
+ } catch (err) {
81
+ setError(avatarErrorText(err))
82
+ } finally {
83
+ setBusy(false)
84
+ }
85
+ }
86
+
87
+ if (!settings) {
88
+ return (
89
+ <span className="settings-popout__hint" role={error ? 'alert' : undefined}>
90
+ {error ?? '…'}
91
+ </span>
92
+ )
93
+ }
94
+
95
+ // Must stay synchronous up to linkInPopup: the popup opens before its first
96
+ // await, inside this click, which is what popup blockers allow.
97
+ const link = (provider: LinkProvider) => {
98
+ setConsent(null)
99
+ void run(async () => {
100
+ const next = await avatarClient.linkInPopup(provider)
101
+ // Linking is choosing, when the account came back with a picture.
102
+ return next.linked[provider]?.avatarUrl ? avatarClient.choose(provider) : next
103
+ })
104
+ }
105
+
106
+ return (
107
+ <div style={S.stack}>
108
+ {(['discord', 'steam'] as const).map(p => {
109
+ const linked = settings.linked[p]
110
+ return (
111
+ <div key={p} style={S.line}>
112
+ <span className="settings-popout__label" style={S.grow}>
113
+ {LABEL[p]}
114
+ {!settings.providers[p]
115
+ ? ' · not available yet'
116
+ : linked
117
+ ? linked.avatarUrl
118
+ ? ' · linked'
119
+ : ' · linked, no picture'
120
+ : ''}
121
+ </span>
122
+ {settings.providers[p] && !linked && (
123
+ <button
124
+ type="button"
125
+ className="settings-popout__btn"
126
+ disabled={busy}
127
+ onClick={() => setConsent(consent === p ? null : p)}
128
+ >
129
+ Link
130
+ </button>
131
+ )}
132
+ {linked && (
133
+ <button
134
+ type="button"
135
+ className="settings-popout__btn"
136
+ disabled={busy}
137
+ onClick={() => void run(() => avatarClient.unlink(p))}
138
+ >
139
+ Unlink
140
+ </button>
141
+ )}
142
+ </div>
143
+ )
144
+ })}
145
+
146
+ {consent && (
147
+ <div style={S.stack}>
148
+ <ul style={S.list}>
149
+ {CONSENT[consent].points.map(t => (
150
+ <li key={t}>{t}</li>
151
+ ))}
152
+ </ul>
153
+ <span style={S.line}>
154
+ <button
155
+ type="button"
156
+ className="settings-popout__btn settings-popout__btn--primary"
157
+ disabled={busy}
158
+ onClick={() => link(consent)}
159
+ >
160
+ {CONSENT[consent].go}
161
+ </button>
162
+ <button type="button" className="settings-popout__btn" onClick={() => setConsent(null)}>
163
+ Cancel
164
+ </button>
165
+ </span>
166
+ </div>
167
+ )}
168
+
169
+ {error && (
170
+ <span className="settings-popout__error" role="alert">
171
+ {error}
172
+ </span>
173
+ )}
174
+ </div>
175
+ )
176
+ }