@hadoku/task-ui-components 4.23.6 → 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.
@@ -7,29 +7,19 @@
7
7
  * protocol cannot drift from the callback page).
8
8
  *
9
9
  * Everything happens in place: picking, uploading and removing are requests
10
- * from here, and linking Discord or Steam signs in through a popup
11
- * (`linkInPopup`), so the page under the popout never navigates and a game
12
- * 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.
13
12
  *
14
13
  * AvatarBuilder owns the settings and the character table and loads them only
15
14
  * when the dialog opens; this component draws them and sends changes through
16
15
  * `run`, which applies the answer everywhere in the dialog.
17
16
  *
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.
22
- *
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
21
  import React, { useEffect, useState } from 'react'
28
- import {
29
- type AvatarSettings,
30
- type AvatarSource,
31
- type LinkProvider
32
- } from '@wolffm/prefs-client/avatar'
22
+ import { type AvatarSettings, type AvatarSource } from '@wolffm/prefs-client/avatar'
33
23
  import { avatarClient, portraitUrl, type CharacterSet } from '../lib/avatarData'
34
24
 
35
25
  const LABEL: Record<AvatarSource, string> = {
@@ -39,27 +29,6 @@ const LABEL: Record<AvatarSource, string> = {
39
29
  upload: 'Upload'
40
30
  }
41
31
 
42
- const CONSENT: Record<LinkProvider, { points: string[]; go: string }> = {
43
- discord: {
44
- points: [
45
- 'Discord asks you to share your basic profile ("identify").',
46
- 'Kept: your Discord user ID and the address of your profile picture, refreshed while linked.',
47
- 'Not kept: your username, email, servers or any token.',
48
- 'Unlink deletes both.'
49
- ],
50
- go: 'Continue to Discord'
51
- },
52
- steam: {
53
- points: [
54
- 'You sign in on steamcommunity.com; your password never reaches hadoku.me.',
55
- 'Your public Steam profile picture is then read through the Steam Web API, now and periodically while linked.',
56
- 'Kept: your Steam ID and the address of that picture.',
57
- 'Unlink deletes both and stops the reads.'
58
- ],
59
- go: 'Continue to Steam'
60
- }
61
- }
62
-
63
32
  const S = {
64
33
  stack: { display: 'flex', flexDirection: 'column', gap: 10, width: '100%' },
65
34
  img: { width: '100%', height: '100%', objectFit: 'cover', display: 'block' },
@@ -100,8 +69,7 @@ const S = {
100
69
  cursor: 'pointer'
101
70
  },
102
71
  line: { display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' },
103
- grow: { flex: 1, minWidth: 0 },
104
- list: { margin: 0, paddingLeft: 18, fontSize: '0.75rem', lineHeight: 1.45 }
72
+ grow: { flex: 1, minWidth: 0 }
105
73
  } satisfies Record<string, React.CSSProperties>
106
74
 
107
75
  function Picture({ src, style }: { src: string | null; style: React.CSSProperties }) {
@@ -132,7 +100,6 @@ export interface AvatarPickerProps {
132
100
 
133
101
  export function AvatarPicker({ settings, sets, busy, run }: AvatarPickerProps) {
134
102
  const [setIdx, setSetIdx] = useState(0)
135
- const [consent, setConsent] = useState<LinkProvider | null>(null)
136
103
  // Open on the set holding the person's character.
137
104
  useEffect(() => {
138
105
  const at = sets?.findIndex(s => s.characters.some(c => c.id === settings.sprite)) ?? -1
@@ -162,17 +129,6 @@ export function AvatarPicker({ settings, sets, busy, run }: AvatarPickerProps) {
162
129
  const shown = settings.avatar
163
130
  const current = sets?.[setIdx]
164
131
 
165
- // Must stay synchronous up to linkInPopup: the popup opens before its first
166
- // await, inside this click, which is what popup blockers allow.
167
- const link = (provider: LinkProvider) => {
168
- setConsent(null)
169
- run(async () => {
170
- const next = await avatarClient.linkInPopup(provider)
171
- // Linking is choosing, when the account came back with a picture.
172
- return next.linked[provider]?.avatarUrl ? avatarClient.choose(provider) : next
173
- }).catch(reportErr)
174
- }
175
-
176
132
  return (
177
133
  <div style={S.stack}>
178
134
  {settings.choice && settings.choice !== shown.source && (
@@ -189,7 +145,13 @@ export function AvatarPicker({ settings, sets, busy, run }: AvatarPickerProps) {
189
145
  role="radio"
190
146
  aria-checked={shown.source === src}
191
147
  disabled={busy || !available[src]}
192
- 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
+ }
193
155
  style={{
194
156
  ...S.source,
195
157
  ...(shown.source === src ? S.sourceOn : {}),
@@ -249,69 +211,6 @@ export function AvatarPicker({ settings, sets, busy, run }: AvatarPickerProps) {
249
211
  <span className="settings-popout__hint">…</span>
250
212
  ))}
251
213
 
252
- {(['discord', 'steam'] as const).map(p => {
253
- const linked = settings.linked[p]
254
- return (
255
- <div key={p} style={S.line}>
256
- <span className="settings-popout__label" style={S.grow}>
257
- {LABEL[p]}
258
- {!settings.providers[p]
259
- ? ' · not available yet'
260
- : linked
261
- ? linked.avatarUrl
262
- ? ' · linked'
263
- : ' · linked, no picture'
264
- : ''}
265
- </span>
266
- {settings.providers[p] && !linked && (
267
- <button
268
- type="button"
269
- className="settings-popout__btn"
270
- disabled={busy}
271
- onClick={() => setConsent(consent === p ? null : p)}
272
- >
273
- Link
274
- </button>
275
- )}
276
- {linked && (
277
- <button
278
- type="button"
279
- className="settings-popout__btn"
280
- disabled={busy}
281
- onClick={() => {
282
- run(() => avatarClient.unlink(p)).catch(reportErr)
283
- }}
284
- >
285
- Unlink
286
- </button>
287
- )}
288
- </div>
289
- )
290
- })}
291
-
292
- {consent && (
293
- <div style={S.stack}>
294
- <ul style={S.list}>
295
- {CONSENT[consent].points.map(t => (
296
- <li key={t}>{t}</li>
297
- ))}
298
- </ul>
299
- <span style={S.line}>
300
- <button
301
- type="button"
302
- className="settings-popout__btn settings-popout__btn--primary"
303
- disabled={busy}
304
- onClick={() => link(consent)}
305
- >
306
- {CONSENT[consent].go}
307
- </button>
308
- <button type="button" className="settings-popout__btn" onClick={() => setConsent(null)}>
309
- Cancel
310
- </button>
311
- </span>
312
- </div>
313
- )}
314
-
315
214
  <div style={S.line}>
316
215
  <span className="settings-popout__label" style={S.grow}>
317
216
  Upload
@@ -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)
@@ -952,6 +955,28 @@ export function ConnectedSettings({
952
955
  </section>
953
956
  )}
954
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
+
955
980
  {/* A self-made account (member) can delete itself. The confirm is a
956
981
  separate button on its own line, so a double click on Delete
957
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
+ }