@chatwarden/player-react 0.0.0-stage → 0.1.3

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 (84) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +401 -2
  3. package/dist/src/Dialog.d.ts +13 -0
  4. package/dist/src/Dialog.d.ts.map +1 -0
  5. package/dist/src/Dialog.js +83 -0
  6. package/dist/src/Dialog.js.map +1 -0
  7. package/dist/src/MyRecord.d.ts +12 -0
  8. package/dist/src/MyRecord.d.ts.map +1 -0
  9. package/dist/src/MyRecord.js +17 -0
  10. package/dist/src/MyRecord.js.map +1 -0
  11. package/dist/src/NoticeQueue.d.ts +26 -0
  12. package/dist/src/NoticeQueue.d.ts.map +1 -0
  13. package/dist/src/NoticeQueue.js +90 -0
  14. package/dist/src/NoticeQueue.js.map +1 -0
  15. package/dist/src/ReportDialog.d.ts +64 -0
  16. package/dist/src/ReportDialog.d.ts.map +1 -0
  17. package/dist/src/ReportDialog.js +146 -0
  18. package/dist/src/ReportDialog.js.map +1 -0
  19. package/dist/src/RestrictedChatLine.d.ts +12 -0
  20. package/dist/src/RestrictedChatLine.d.ts.map +1 -0
  21. package/dist/src/RestrictedChatLine.js +19 -0
  22. package/dist/src/RestrictedChatLine.js.map +1 -0
  23. package/dist/src/RulesPage.d.ts +10 -0
  24. package/dist/src/RulesPage.d.ts.map +1 -0
  25. package/dist/src/RulesPage.js +17 -0
  26. package/dist/src/RulesPage.js.map +1 -0
  27. package/dist/src/client.d.ts +51 -0
  28. package/dist/src/client.d.ts.map +1 -0
  29. package/dist/src/client.js +98 -0
  30. package/dist/src/client.js.map +1 -0
  31. package/dist/src/constants.d.ts +5 -0
  32. package/dist/src/constants.d.ts.map +1 -0
  33. package/dist/src/constants.js +5 -0
  34. package/dist/src/constants.js.map +1 -0
  35. package/dist/src/format.d.ts +14 -0
  36. package/dist/src/format.d.ts.map +1 -0
  37. package/dist/src/format.js +55 -0
  38. package/dist/src/format.js.map +1 -0
  39. package/dist/src/hooks.d.ts +15 -0
  40. package/dist/src/hooks.d.ts.map +1 -0
  41. package/dist/src/hooks.js +29 -0
  42. package/dist/src/hooks.js.map +1 -0
  43. package/dist/src/i18n.d.ts +153 -0
  44. package/dist/src/i18n.d.ts.map +1 -0
  45. package/dist/src/i18n.js +191 -0
  46. package/dist/src/i18n.js.map +1 -0
  47. package/dist/src/index.d.ts +21 -0
  48. package/dist/src/index.d.ts.map +1 -0
  49. package/dist/src/index.js +11 -0
  50. package/dist/src/index.js.map +1 -0
  51. package/dist/src/reporting.d.ts +20 -0
  52. package/dist/src/reporting.d.ts.map +1 -0
  53. package/dist/src/reporting.js +45 -0
  54. package/dist/src/reporting.js.map +1 -0
  55. package/dist/src/safeHref.d.ts +5 -0
  56. package/dist/src/safeHref.d.ts.map +1 -0
  57. package/dist/src/safeHref.js +41 -0
  58. package/dist/src/safeHref.js.map +1 -0
  59. package/dist/src/transport.d.ts +69 -0
  60. package/dist/src/transport.d.ts.map +1 -0
  61. package/dist/src/transport.js +74 -0
  62. package/dist/src/transport.js.map +1 -0
  63. package/dist/src/ui.d.ts +91 -0
  64. package/dist/src/ui.d.ts.map +1 -0
  65. package/dist/src/ui.js +116 -0
  66. package/dist/src/ui.js.map +1 -0
  67. package/package.json +73 -4
  68. package/src/Dialog.tsx +107 -0
  69. package/src/MyRecord.tsx +60 -0
  70. package/src/NoticeQueue.tsx +203 -0
  71. package/src/ReportDialog.tsx +319 -0
  72. package/src/RestrictedChatLine.tsx +42 -0
  73. package/src/RulesPage.tsx +49 -0
  74. package/src/client.ts +132 -0
  75. package/src/constants.ts +5 -0
  76. package/src/format.ts +53 -0
  77. package/src/hooks.ts +41 -0
  78. package/src/i18n.ts +211 -0
  79. package/src/index.ts +32 -0
  80. package/src/reporting.ts +51 -0
  81. package/src/safeHref.ts +36 -0
  82. package/src/styles.css +500 -0
  83. package/src/transport.ts +134 -0
  84. package/src/ui.tsx +199 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ui.d.ts","sourceRoot":"","sources":["../../src/ui.tsx"],"names":[],"mappings":"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"}
package/dist/src/ui.js ADDED
@@ -0,0 +1,116 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useMemo } from 'react';
3
+ import { createPortal } from 'react-dom';
4
+ import { WARNING_DAYS } from './constants.js';
5
+ import { dayText, timeLeft, untilText } from './format.js';
6
+ import { createT, en } from './i18n.js';
7
+ import { safeHref } from './safeHref.js';
8
+ const DAY = 86_400_000;
9
+ export function useUi({ t, locale = 'en', now, timeZone }) {
10
+ const words = useMemo(() => t ?? createT(en, locale), [t, locale]);
11
+ return { t: words, locale, now: now ?? Date.now, timeZone };
12
+ }
13
+ /** Renders `children` where they are, or into `container` when one is given. */
14
+ export function Portal({ container, children }) {
15
+ return container === undefined || container === null ? _jsx(_Fragment, { children: children }) : createPortal(children, container);
16
+ }
17
+ /** The root every component renders: the one class all the stylesheet's rules are scoped under. */
18
+ export function Root({ theme, className, children }) {
19
+ return (_jsx("div", { className: className === undefined ? 'cw-player' : `cw-player ${className}`, ...(theme === undefined ? {} : { 'data-cw-theme': theme }), children: children }));
20
+ }
21
+ const CARD_OF = { warning: 'warning', chat: 'restriction', area: 'restriction', ban: 'ban', note: 'note' };
22
+ /**
23
+ * The referee card: a yellow card for a warning, half yellow and half red for a restriction, red for a ban, and a dashed outline
24
+ * for a note. The same card as the moderator hub's, so a sanction looks the same to staff and player. Decoration: the kind is
25
+ * always written beside it. A kind with no card (a thank-you for a report) draws nothing.
26
+ */
27
+ export function Card({ kind }) {
28
+ const card = Object.hasOwn(CARD_OF, kind) ? CARD_OF[kind] : undefined;
29
+ return card === undefined ? null : _jsx("span", { className: `cw-card cw-card--${card}`, "aria-hidden": "true" });
30
+ }
31
+ /** The plain sentence for a failed call. The server's own message is never shown. */
32
+ export function errorText(t, e) {
33
+ if (e.status === 0)
34
+ return e.code === 'network' ? t('player.error.network') : t('player.error.generic');
35
+ if (e.status === 401)
36
+ return t('player.error.unauthorized');
37
+ if (e.status === 403)
38
+ return t('player.error.forbidden');
39
+ if (e.status === 404)
40
+ return t('player.error.notFound');
41
+ if (e.status === 429)
42
+ return e.retryAfter === undefined ? t('player.error.rateLimited') : t('player.error.rateLimited.after', { count: e.retryAfter });
43
+ if (e.status >= 500)
44
+ return t('player.error.server');
45
+ return t('player.error.generic');
46
+ }
47
+ /** A failed load: what could not be loaded, why, and a way to try again. */
48
+ export function ErrorBlock({ ui, what, error, onRetry }) {
49
+ return (_jsxs("div", { className: "cw-error", role: "alert", children: [_jsxs("p", { children: [_jsx("strong", { children: what }), " ", errorText(ui.t, error)] }), _jsx("button", { type: "button", className: "cw-btn", onClick: onRetry, children: ui.t('player.state.retry') })] }));
50
+ }
51
+ /** "Think this is a mistake?", when the adopter gave an address that is safe to link to. */
52
+ export function AppealLink({ ui, href }) {
53
+ const safe = safeHref(href);
54
+ if (safe === null)
55
+ return null;
56
+ return (_jsx("p", { className: "cw-appeal", children: _jsx("a", { className: "cw-link", href: safe, target: "_blank", rel: "noopener noreferrer", children: ui.t('player.notice.appeal') }) }));
57
+ }
58
+ const TITLE = {
59
+ warning: 'player.notice.title.warning',
60
+ chat: 'player.notice.title.chat',
61
+ area: 'player.notice.title.area',
62
+ ban: 'player.notice.title.ban',
63
+ report_outcome: 'player.notice.title.report_outcome',
64
+ };
65
+ const BODY = {
66
+ warning: 'player.notice.body.warning',
67
+ chat: 'player.notice.body.chat',
68
+ area: 'player.notice.body.area',
69
+ ban: 'player.notice.body.ban',
70
+ report_outcome: 'player.notice.body.report_outcome',
71
+ };
72
+ const KIND = {
73
+ warning: 'player.record.kind.warning',
74
+ chat: 'player.record.kind.chat',
75
+ area: 'player.record.kind.area',
76
+ ban: 'player.record.kind.ban',
77
+ };
78
+ export const titleKey = (kind) => (Object.hasOwn(TITLE, kind) ? TITLE[kind] : 'player.notice.title.fallback');
79
+ export const bodyKey = (kind) => (Object.hasOwn(BODY, kind) ? BODY[kind] : 'player.notice.body.fallback');
80
+ export const kindKey = (kind) => (Object.hasOwn(KIND, kind) ? KIND[kind] : 'player.record.kind.fallback');
81
+ /** A sanction a player can contest (a thank-you for a report is not one). */
82
+ export const isSanction = (kind) => kind === 'warning' || kind === 'chat' || kind === 'area' || kind === 'ban';
83
+ /** The facts of a notice, as a term list: the rule, when it was given (the record only), until when, and the reference code. */
84
+ export function Facts({ ui, notice, record = false }) {
85
+ const { t, locale, timeZone } = ui;
86
+ const rows = [];
87
+ if (notice.ruleLabel !== null)
88
+ rows.push([t('player.notice.rule'), notice.ruleLabel]);
89
+ if (record)
90
+ rows.push([t('player.notice.given'), untilText(notice.createdAt, locale, timeZone)]);
91
+ if (notice.kind === 'warning') {
92
+ // A warning has no end of its own: it stays on the record for 90 days from when it was made.
93
+ rows.push([t('player.notice.onRecordUntil'), untilText(notice.createdAt + WARNING_DAYS * DAY, locale, timeZone)]);
94
+ }
95
+ else if (isSanction(notice.kind) || notice.until !== null) {
96
+ if (notice.until === null)
97
+ rows.push([t('player.notice.until'), t('player.notice.noEnd')]);
98
+ else {
99
+ const when = untilText(notice.until, locale, timeZone);
100
+ const left = timeLeft(notice.until, ui.now(), locale);
101
+ rows.push(left === null ? [t('player.notice.ended'), when] : [t('player.notice.until'), t('player.notice.untilLeft', { when, left })]);
102
+ }
103
+ }
104
+ if (notice.refCode !== null)
105
+ rows.push([t('player.notice.ref'), notice.refCode]);
106
+ if (rows.length === 0)
107
+ return null;
108
+ return (_jsx("dl", { className: "cw-facts", children: rows.map(([term, value]) => (_jsxs("div", { className: "cw-fact", children: [_jsx("dt", { children: term }), _jsx("dd", { children: value })] }, term))) }));
109
+ }
110
+ /** The player's own lines that led to a notice, as text with their day. Never HTML. */
111
+ export function Lines({ ui, notice, id }) {
112
+ if (notice.excerpt.length === 0)
113
+ return null;
114
+ return (_jsxs(_Fragment, { children: [_jsx("p", { id: id, className: "cw-lines-label", children: ui.t('player.notice.lines') }), _jsx("ul", { className: "cw-lines", "aria-labelledby": id, children: notice.excerpt.map((line, i) => (_jsxs("li", { children: [_jsx("span", { className: "cw-line-day", children: dayText(line.day, ui.locale) }), _jsx("span", { className: "cw-line-text", children: line.text })] }, i))) })] }));
115
+ }
116
+ //# sourceMappingURL=ui.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ui.js","sourceRoot":"","sources":["../../src/ui.tsx"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,75 @@
1
1
  {
2
2
  "name": "@chatwarden/player-react",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.3",
4
+ "private": false,
5
+ "description": "Player-facing React components for Chatwarden: the notice dialog, My record, the rules page, a report dialog and a restricted-chat line, over the player HTTP routes.",
6
+ "license": "Apache-2.0",
7
+ "type": "module",
8
+ "engines": {
9
+ "node": ">=22.13"
10
+ },
11
+ "keywords": [
12
+ "chatwarden",
13
+ "moderation",
14
+ "chat",
15
+ "react",
16
+ "player",
17
+ "notices"
18
+ ],
19
+ "homepage": "https://github.com/hainix/chatwarden/tree/main/packages/player-react#readme",
20
+ "bugs": {
21
+ "url": "https://github.com/hainix/chatwarden/issues"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/hainix/chatwarden.git",
26
+ "directory": "packages/player-react"
27
+ },
28
+ "main": "./dist/src/index.js",
29
+ "types": "./dist/src/index.d.ts",
30
+ "exports": {
31
+ ".": {
32
+ "types": "./dist/src/index.d.ts",
33
+ "default": "./dist/src/index.js"
34
+ },
35
+ "./styles.css": "./src/styles.css"
36
+ },
37
+ "sideEffects": [
38
+ "./src/styles.css"
39
+ ],
40
+ "files": [
41
+ "dist/src",
42
+ "src",
43
+ "README.md",
44
+ "LICENSE"
45
+ ],
46
+ "scripts": {
47
+ "prepack": "tsc -b"
48
+ },
49
+ "peerDependencies": {
50
+ "@chatwarden/core": "^0.1.2",
51
+ "react": "^18.3.0 || ^19.0.0",
52
+ "react-dom": "^18.3.0 || ^19.0.0"
53
+ },
54
+ "devDependencies": {
55
+ "@chatwarden/core": "0.1.3",
56
+ "@chatwarden/http": "0.1.3",
57
+ "@chatwarden/hub-react": "0.1.3",
58
+ "@chatwarden/store-memory": "0.1.3",
59
+ "@testing-library/dom": "^10.4.2",
60
+ "@testing-library/jest-dom": "^7.0.1",
61
+ "@testing-library/react": "^16.3.3",
62
+ "@testing-library/user-event": "^14.6.9",
63
+ "@types/react": "^19.3.0",
64
+ "@types/react-dom": "^19.3.0",
65
+ "axe-core": "^4.14.0",
66
+ "esbuild": "0.28.2",
67
+ "jsdom": "^29.1.1",
68
+ "react": "^19.3.1",
69
+ "react-dom": "^19.3.1",
70
+ "typescript": "~5.9.3"
71
+ },
72
+ "publishConfig": {
73
+ "access": "public"
74
+ }
75
+ }
package/src/Dialog.tsx ADDED
@@ -0,0 +1,107 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import type { KeyboardEvent, ReactNode, RefObject } from 'react';
3
+
4
+ // A modal window: it takes focus when it opens, keeps focus inside, and hands focus back to what had it. It is a plain ARIA dialog
5
+ // (not <dialog>), so it works the same in every embedded browser a game may use. A click outside never closes it: a notice has to
6
+ // be acknowledged, and a half-written report is not lost by a stray click.
7
+ //
8
+ // Focus is kept in three ways, because Tab alone is not enough: the keydown handler wraps Tab at the ends, a click on the backdrop
9
+ // does not move focus (mousedown is cancelled), and a document-wide `focusin` guard pulls focus back if anything outside the window
10
+ // gets it (a script, a click on a game control that is still reachable).
11
+
12
+ const FOCUSABLE = 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
13
+
14
+ /** The controls Tab visits, in order. */
15
+ const tabStops = (box: HTMLElement): HTMLElement[] => [...box.querySelectorAll<HTMLElement>(FOCUSABLE)];
16
+
17
+ export function Dialog({
18
+ labelledBy,
19
+ initialFocus,
20
+ onEscape,
21
+ returnFocusTo,
22
+ children,
23
+ }: {
24
+ /** The id of the heading that names the window. */
25
+ labelledBy: string;
26
+ /** What takes focus when it opens: the heading. */
27
+ initialFocus: RefObject<HTMLElement | null>;
28
+ /** Called on Escape. Leave it out when the window must not be dismissed. */
29
+ onEscape?: () => void;
30
+ /** Where focus goes on close when the element that had it before is gone. */
31
+ returnFocusTo?: () => HTMLElement | null;
32
+ children: ReactNode;
33
+ }) {
34
+ const box = useRef<HTMLDivElement>(null);
35
+ const fallback = useRef(returnFocusTo);
36
+ fallback.current = returnFocusTo;
37
+ // What had focus before the window opened, read while rendering for the first time: a child's own effect (which moves focus to
38
+ // its heading) runs before this component's effects, so reading it in an effect would find the heading.
39
+ const [before] = useState<Element | null>(() => (typeof document === 'undefined' ? null : document.activeElement));
40
+
41
+ useEffect(() => {
42
+ (initialFocus.current ?? box.current)?.focus();
43
+ const onFocusIn = (e: FocusEvent): void => {
44
+ const target = e.target;
45
+ const inner = box.current;
46
+ if (inner !== null && target instanceof Node && !inner.contains(target)) (initialFocus.current ?? inner).focus();
47
+ };
48
+ document.addEventListener('focusin', onFocusIn);
49
+ return () => {
50
+ // Stop guarding first, so giving focus back is not pulled in again.
51
+ document.removeEventListener('focusin', onFocusIn);
52
+ // Give focus back, unless what had it is gone (the page moved on while the window was open): then to `returnFocusTo()`, if
53
+ // the host gave one, and otherwise to wherever the browser leaves it (the page body).
54
+ if (before instanceof HTMLElement && before.isConnected) before.focus();
55
+ else {
56
+ try {
57
+ fallback.current?.()?.focus();
58
+ } catch {
59
+ // A host callback that throws must not stop the window closing.
60
+ }
61
+ }
62
+ };
63
+ // Once, when the window opens.
64
+ }, []);
65
+
66
+ const onKeyDown = (e: KeyboardEvent<HTMLDivElement>): void => {
67
+ if (e.key === 'Escape') {
68
+ if (onEscape === undefined) return;
69
+ e.preventDefault();
70
+ e.stopPropagation();
71
+ onEscape();
72
+ return;
73
+ }
74
+ if (e.key !== 'Tab' || box.current === null) return;
75
+ const items = tabStops(box.current);
76
+ if (items.length === 0) {
77
+ e.preventDefault();
78
+ return;
79
+ }
80
+ const first = items[0] as HTMLElement;
81
+ const last = items[items.length - 1] as HTMLElement;
82
+ const active = document.activeElement;
83
+ // From the heading (or the box), which is not in the list, Tab goes to the first control and Shift+Tab to the last.
84
+ const inList = items.includes(active as HTMLElement);
85
+ if (e.shiftKey && (active === first || !inList)) {
86
+ e.preventDefault();
87
+ last.focus();
88
+ } else if (!e.shiftKey && (active === last || !inList)) {
89
+ e.preventDefault();
90
+ first.focus();
91
+ }
92
+ };
93
+
94
+ return (
95
+ <div
96
+ className="cw-scrim"
97
+ onMouseDown={(e) => {
98
+ // A press on the backdrop would drop focus to the page body, and the next Tab would leave the window: keep it where it is.
99
+ if (e.target === e.currentTarget) e.preventDefault();
100
+ }}
101
+ >
102
+ <div ref={box} className="cw-dialog" role="dialog" aria-modal="true" aria-labelledby={labelledBy} tabIndex={-1} onKeyDown={onKeyDown}>
103
+ {children}
104
+ </div>
105
+ </div>
106
+ );
107
+ }
@@ -0,0 +1,60 @@
1
+ import { useId } from 'react';
2
+ import type { PlayerClient } from './client.js';
3
+ import { usePlayerData } from './hooks.js';
4
+ import { WARNING_DAYS } from './constants.js';
5
+ import { AppealLink, Card, ErrorBlock, Facts, kindKey, Lines, Root, useUi } from './ui.js';
6
+ import type { PlayerUiProps } from './ui.js';
7
+
8
+ export interface MyRecordProps extends PlayerUiProps {
9
+ client: PlayerClient;
10
+ /** Where "Think this is a mistake?" goes: an `http(s)` address or a path starting with `/`. Left out when absent or unsafe. */
11
+ appealUrl?: string;
12
+ /** Change it to load the record again. */
13
+ refreshKey?: number | string;
14
+ }
15
+
16
+ /** "My record": what is on the player's record now, newest first, from `GET /record`; or a calm line when it is clear. */
17
+ export function MyRecord(props: MyRecordProps) {
18
+ const { client, appealUrl, refreshKey, theme, className } = props;
19
+ const ui = useUi(props);
20
+ const { t } = ui;
21
+ const state = usePlayerData(() => client.record(), refreshKey);
22
+ const titleId = useId();
23
+ const linesId = useId();
24
+ const items = state.data === undefined ? [] : [...state.data].sort((a, b) => b.createdAt - a.createdAt);
25
+
26
+ return (
27
+ <Root theme={theme} className={className}>
28
+ <section className="cw-section" aria-labelledby={titleId}>
29
+ <h2 id={titleId} className="cw-title">
30
+ {t('player.record.title')}
31
+ </h2>
32
+ {state.error !== null && <ErrorBlock ui={ui} what={t('player.record.loadFailed')} error={state.error} onRetry={state.reload} />}
33
+ {state.data === undefined && state.error === null && (
34
+ <p className="cw-state" role="status">
35
+ {t('player.record.loading')}
36
+ </p>
37
+ )}
38
+ {state.data !== undefined && items.length === 0 && <p className="cw-state">{t('player.record.empty')}</p>}
39
+ {items.length > 0 && (
40
+ <>
41
+ <p className="cw-body">{t('player.record.intro', { days: WARNING_DAYS })}</p>
42
+ <AppealLink ui={ui} href={appealUrl} />
43
+ <ul className="cw-items" aria-labelledby={titleId}>
44
+ {items.map((notice, i) => (
45
+ <li key={notice.id} className="cw-item">
46
+ <div className="cw-head">
47
+ <Card kind={notice.kind} />
48
+ <h3 className="cw-kind">{t(kindKey(notice.kind))}</h3>
49
+ </div>
50
+ <Facts ui={ui} notice={notice} record />
51
+ <Lines ui={ui} notice={notice} id={`${linesId}-${i}`} />
52
+ </li>
53
+ ))}
54
+ </ul>
55
+ </>
56
+ )}
57
+ </section>
58
+ </Root>
59
+ );
60
+ }
@@ -0,0 +1,203 @@
1
+ import { useEffect, useId, useRef, useState } from 'react';
2
+ import type { RefObject } from 'react';
3
+ import type { PlayerNotice } from '@chatwarden/core';
4
+ import { toPlayerError } from './client.js';
5
+ import type { PlayerClient, PlayerError } from './client.js';
6
+ import { Dialog } from './Dialog.js';
7
+ import { usePlayerData } from './hooks.js';
8
+ import { AppealLink, bodyKey, Card, errorText, Facts, isSanction, Lines, Portal, Root, titleKey, useUi } from './ui.js';
9
+ import type { DialogHostProps, PlayerUiProps, Ui } from './ui.js';
10
+
11
+ export interface NoticeQueueProps extends PlayerUiProps, DialogHostProps {
12
+ client: PlayerClient;
13
+ /**
14
+ * Whether a notice may appear now. Pass `false` during a match: notices are held (kept, not dropped, not acknowledged) and
15
+ * appear, oldest first, when it turns `true` again; the list is asked for once more at that moment, so a notice that arrived during
16
+ * the match is not missed. Default: `true`.
17
+ */
18
+ canShow?: boolean;
19
+ /** Where "Think this is a mistake?" goes: an `https` address or a relative one (`/appeal`). Left out when absent or unsafe. */
20
+ appealUrl?: string;
21
+ /** Change it to ask the server again (for example when your own push channel says a notice is pending). */
22
+ refreshKey?: number | string;
23
+ /** Called once for each notice the player acknowledged. */
24
+ onAcknowledged?: (notice: PlayerNotice) => void;
25
+ /** Called with how many notices are waiting (shown or held) each time that number changes, after the first load. */
26
+ onPendingChange?: (count: number) => void;
27
+ }
28
+
29
+ /**
30
+ * Shows the player's pending notices, oldest first, one at a time in a modal dialog. Every notice is acknowledged (a warning with
31
+ * "Acknowledge", the others with "Got it"); the dialog closes after the last one and focus returns to where it was.
32
+ */
33
+ export function NoticeQueue(props: NoticeQueueProps) {
34
+ const { client, canShow = true, appealUrl, refreshKey, onAcknowledged, onPendingChange, theme, className, container, returnFocusTo } = props;
35
+ const ui = useUi(props);
36
+ const state = usePlayerData(() => client.notices(), refreshKey);
37
+ // The ids acknowledged since this list arrived. A newer list replaces both, so the server stays the truth.
38
+ const [done, setDone] = useState<{ list: PlayerNotice[] | undefined; ids: readonly string[] }>({ list: undefined, ids: [] });
39
+ const gone = done.list === state.data ? done.ids : [];
40
+ const queue = (state.data ?? []).filter((n) => !gone.includes(n.id)).sort((a, b) => a.createdAt - b.createdAt);
41
+ const count = queue.length;
42
+ const loaded = state.data !== undefined;
43
+ // "Close for now" after a failed acknowledge hides the queue until a newer list arrives (a refresh, or the next release of a hold).
44
+ const [later, setLater] = useState<PlayerNotice[] | undefined>(undefined);
45
+ const titleId = useId();
46
+ const heading = useRef<HTMLHeadingElement>(null);
47
+ const reload = state.reload;
48
+
49
+ // Releasing a hold asks the server once more: a warning that landed during the match must be seen before the next one.
50
+ const wasShown = useRef(canShow);
51
+ useEffect(() => {
52
+ if (canShow && !wasShown.current) reload();
53
+ wasShown.current = canShow;
54
+ }, [canShow, reload]);
55
+
56
+ const told = useRef(onPendingChange);
57
+ told.current = onPendingChange;
58
+ useEffect(() => {
59
+ if (loaded) told.current?.(count);
60
+ }, [loaded, count]);
61
+
62
+ if (!canShow) return null;
63
+ if (state.data === undefined) {
64
+ if (state.error === null) return null;
65
+ return (
66
+ <Root theme={theme} className={className}>
67
+ <div className="cw-error" role="alert">
68
+ <p>
69
+ <strong>{ui.t('player.notices.loadFailed')}</strong> {errorText(ui.t, state.error)}
70
+ </p>
71
+ <button type="button" className="cw-btn" onClick={state.reload}>
72
+ {ui.t('player.state.retry')}
73
+ </button>
74
+ </div>
75
+ </Root>
76
+ );
77
+ }
78
+ const current = queue[0];
79
+ if (current === undefined || later === state.data) return null;
80
+
81
+ const acknowledged = (notice: PlayerNotice): void => {
82
+ setDone({ list: state.data, ids: [...gone, notice.id] });
83
+ onAcknowledged?.(notice);
84
+ };
85
+
86
+ return (
87
+ <Portal container={container}>
88
+ <Root theme={theme} className={className}>
89
+ {/* One dialog for the whole queue: it opens once and closes once, so focus comes back to where it was after the last notice. */}
90
+ <Dialog labelledBy={titleId} initialFocus={heading} {...(returnFocusTo === undefined ? {} : { returnFocusTo })}>
91
+ <NoticeCard
92
+ key={current.id}
93
+ ui={ui}
94
+ client={client}
95
+ notice={current}
96
+ total={count}
97
+ appealUrl={appealUrl}
98
+ titleId={titleId}
99
+ heading={heading}
100
+ onDone={acknowledged}
101
+ onLater={() => setLater(state.data)}
102
+ />
103
+ </Dialog>
104
+ </Root>
105
+ </Portal>
106
+ );
107
+ }
108
+
109
+ function NoticeCard({
110
+ ui,
111
+ client,
112
+ notice,
113
+ total,
114
+ appealUrl,
115
+ titleId,
116
+ heading,
117
+ onDone,
118
+ onLater,
119
+ }: {
120
+ ui: Ui;
121
+ client: PlayerClient;
122
+ notice: PlayerNotice;
123
+ /** How many notices are waiting, this one included. It is always the first. */
124
+ total: number;
125
+ appealUrl: string | undefined;
126
+ titleId: string;
127
+ heading: RefObject<HTMLHeadingElement | null>;
128
+ onDone: (notice: PlayerNotice) => void;
129
+ onLater: () => void;
130
+ }) {
131
+ const { t } = ui;
132
+ const linesId = useId();
133
+ const sending = useRef(false);
134
+ const [busy, setBusy] = useState(false);
135
+ const [failure, setFailure] = useState<PlayerError | null>(null);
136
+
137
+ // Each notice, not only the first, takes focus at its heading: it is what a screen reader should read next.
138
+ useEffect(() => heading.current?.focus(), []);
139
+
140
+ const acknowledge = async (): Promise<void> => {
141
+ // One request at a time: a second click, or a second Enter, while one is on its way does nothing.
142
+ if (sending.current) return;
143
+ sending.current = true;
144
+ setBusy(true);
145
+ setFailure(null);
146
+ try {
147
+ await client.ack(notice.id);
148
+ // `false` (a 200 that is not ours) and a 404 both mean the notice is no longer pending: it is done.
149
+ onDone(notice);
150
+ } catch (e) {
151
+ const error = toPlayerError(e);
152
+ if (error.status === 404) onDone(notice);
153
+ else setFailure(error);
154
+ } finally {
155
+ sending.current = false;
156
+ setBusy(false);
157
+ }
158
+ };
159
+
160
+ const mustAck = notice.kind === 'warning' || notice.mustAcknowledge;
161
+ return (
162
+ <>
163
+ {/* Always drawn, so a screen reader that has seen it empty hears it when a second notice arrives. */}
164
+ <p className="cw-position" aria-live="polite">
165
+ {total > 1 ? t('player.notice.position', { n: 1, total }) : null}
166
+ </p>
167
+ <div className="cw-head">
168
+ <Card kind={notice.kind} />
169
+ <h2 id={titleId} ref={heading} className="cw-title" tabIndex={-1}>
170
+ {t(titleKey(notice.kind))}
171
+ </h2>
172
+ </div>
173
+ <p className="cw-body">{t(bodyKey(notice.kind))}</p>
174
+ <Facts ui={ui} notice={notice} />
175
+ {notice.refCode !== null && <p className="cw-hint">{t('player.notice.refHint')}</p>}
176
+ <Lines ui={ui} notice={notice} id={linesId} />
177
+ {isSanction(notice.kind) && <AppealLink ui={ui} href={appealUrl} />}
178
+ {mustAck && <p className="cw-hint">{t('player.notice.todo.warning')}</p>}
179
+ {failure !== null && (
180
+ <div className="cw-error" role="alert">
181
+ <p>
182
+ {t('player.notice.ackFailed')} {errorText(t, failure)}
183
+ </p>
184
+ {/* The main button below tries again; this is the way out when it keeps failing. The notice stays pending. */}
185
+ <button type="button" className="cw-btn" onClick={onLater}>
186
+ {t('player.notice.closeForNow')}
187
+ </button>
188
+ </div>
189
+ )}
190
+ <div className="cw-actions">
191
+ <button
192
+ type="button"
193
+ className="cw-btn cw-btn--primary"
194
+ aria-disabled={busy || undefined}
195
+ aria-busy={busy || undefined}
196
+ onClick={() => void acknowledge()}
197
+ >
198
+ {t(mustAck ? 'player.notice.acknowledge' : 'player.notice.gotIt')}
199
+ </button>
200
+ </div>
201
+ </>
202
+ );
203
+ }