@chatwarden/player-react 0.0.0-stage → 0.1.2

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 (94) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +377 -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 +94 -0
  6. package/dist/src/Dialog.js.map +1 -0
  7. package/dist/src/FollowUp.d.ts +35 -0
  8. package/dist/src/FollowUp.d.ts.map +1 -0
  9. package/dist/src/FollowUp.js +69 -0
  10. package/dist/src/FollowUp.js.map +1 -0
  11. package/dist/src/MyRecord.d.ts +12 -0
  12. package/dist/src/MyRecord.d.ts.map +1 -0
  13. package/dist/src/MyRecord.js +17 -0
  14. package/dist/src/MyRecord.js.map +1 -0
  15. package/dist/src/NoticeQueue.d.ts +26 -0
  16. package/dist/src/NoticeQueue.d.ts.map +1 -0
  17. package/dist/src/NoticeQueue.js +90 -0
  18. package/dist/src/NoticeQueue.js.map +1 -0
  19. package/dist/src/Picker.d.ts +14 -0
  20. package/dist/src/Picker.d.ts.map +1 -0
  21. package/dist/src/Picker.js +14 -0
  22. package/dist/src/Picker.js.map +1 -0
  23. package/dist/src/ReportDialog.d.ts +50 -0
  24. package/dist/src/ReportDialog.d.ts.map +1 -0
  25. package/dist/src/ReportDialog.js +157 -0
  26. package/dist/src/ReportDialog.js.map +1 -0
  27. package/dist/src/RestrictedChatLine.d.ts +12 -0
  28. package/dist/src/RestrictedChatLine.d.ts.map +1 -0
  29. package/dist/src/RestrictedChatLine.js +19 -0
  30. package/dist/src/RestrictedChatLine.js.map +1 -0
  31. package/dist/src/RulesPage.d.ts +10 -0
  32. package/dist/src/RulesPage.d.ts.map +1 -0
  33. package/dist/src/RulesPage.js +17 -0
  34. package/dist/src/RulesPage.js.map +1 -0
  35. package/dist/src/client.d.ts +51 -0
  36. package/dist/src/client.d.ts.map +1 -0
  37. package/dist/src/client.js +98 -0
  38. package/dist/src/client.js.map +1 -0
  39. package/dist/src/constants.d.ts +5 -0
  40. package/dist/src/constants.d.ts.map +1 -0
  41. package/dist/src/constants.js +5 -0
  42. package/dist/src/constants.js.map +1 -0
  43. package/dist/src/format.d.ts +14 -0
  44. package/dist/src/format.d.ts.map +1 -0
  45. package/dist/src/format.js +55 -0
  46. package/dist/src/format.js.map +1 -0
  47. package/dist/src/hooks.d.ts +15 -0
  48. package/dist/src/hooks.d.ts.map +1 -0
  49. package/dist/src/hooks.js +29 -0
  50. package/dist/src/hooks.js.map +1 -0
  51. package/dist/src/i18n.d.ts +129 -0
  52. package/dist/src/i18n.d.ts.map +1 -0
  53. package/dist/src/i18n.js +167 -0
  54. package/dist/src/i18n.js.map +1 -0
  55. package/dist/src/index.d.ts +21 -0
  56. package/dist/src/index.d.ts.map +1 -0
  57. package/dist/src/index.js +11 -0
  58. package/dist/src/index.js.map +1 -0
  59. package/dist/src/reporting.d.ts +20 -0
  60. package/dist/src/reporting.d.ts.map +1 -0
  61. package/dist/src/reporting.js +45 -0
  62. package/dist/src/reporting.js.map +1 -0
  63. package/dist/src/safeHref.d.ts +5 -0
  64. package/dist/src/safeHref.d.ts.map +1 -0
  65. package/dist/src/safeHref.js +41 -0
  66. package/dist/src/safeHref.js.map +1 -0
  67. package/dist/src/transport.d.ts +69 -0
  68. package/dist/src/transport.d.ts.map +1 -0
  69. package/dist/src/transport.js +74 -0
  70. package/dist/src/transport.js.map +1 -0
  71. package/dist/src/ui.d.ts +91 -0
  72. package/dist/src/ui.d.ts.map +1 -0
  73. package/dist/src/ui.js +116 -0
  74. package/dist/src/ui.js.map +1 -0
  75. package/package.json +73 -4
  76. package/src/Dialog.tsx +117 -0
  77. package/src/FollowUp.tsx +139 -0
  78. package/src/MyRecord.tsx +60 -0
  79. package/src/NoticeQueue.tsx +203 -0
  80. package/src/Picker.tsx +60 -0
  81. package/src/ReportDialog.tsx +304 -0
  82. package/src/RestrictedChatLine.tsx +42 -0
  83. package/src/RulesPage.tsx +49 -0
  84. package/src/client.ts +132 -0
  85. package/src/constants.ts +5 -0
  86. package/src/format.ts +53 -0
  87. package/src/hooks.ts +41 -0
  88. package/src/i18n.ts +187 -0
  89. package/src/index.ts +32 -0
  90. package/src/reporting.ts +51 -0
  91. package/src/safeHref.ts +36 -0
  92. package/src/styles.css +516 -0
  93. package/src/transport.ts +134 -0
  94. package/src/ui.tsx +199 -0
@@ -0,0 +1,74 @@
1
+ /** The query as strings, with `undefined` values dropped. */
2
+ function queryOf(query) {
3
+ const out = {};
4
+ for (const [key, value] of Object.entries(query ?? {}))
5
+ if (value !== undefined)
6
+ out[key] = String(value);
7
+ return out;
8
+ }
9
+ function lowerCased(headers) {
10
+ const out = {};
11
+ for (const [key, value] of Object.entries(headers ?? {}))
12
+ out[key.toLowerCase()] = value;
13
+ return out;
14
+ }
15
+ /**
16
+ * A transport that calls `createRouter(playerRoutes(...))` directly, in the same process: no network. A demo and this
17
+ * package's tests use it, so the components run the real player routes. A response body goes through JSON on the way out, so
18
+ * the components see exactly what a fetch would deliver.
19
+ */
20
+ export function routerTransport(handle, options = {}) {
21
+ return async (req) => {
22
+ const request = {
23
+ method: req.method,
24
+ path: req.path,
25
+ query: queryOf(req.query),
26
+ headers: { ...options.headers },
27
+ body: undefined,
28
+ };
29
+ // The routes read `rawBody`, never `body` (a pre-parsed body would skip the size cap and the key check).
30
+ if (req.body !== undefined)
31
+ request.rawBody = JSON.stringify(req.body);
32
+ const res = await handle(request);
33
+ const body = typeof res.body === 'string' ? res.body : res.body === undefined ? null : JSON.parse(JSON.stringify(res.body));
34
+ return { status: res.status, headers: lowerCased(res.headers), body };
35
+ };
36
+ }
37
+ /** The default transport for a real game: `fetch` against the routes your server mounts. */
38
+ export function fetchTransport(options) {
39
+ if (typeof options?.baseUrl !== 'string')
40
+ throw new TypeError('fetchTransport: baseUrl is required (for example "/player")');
41
+ // "/" and "" both mean the server root: routes are then "/notices", "/record", ...
42
+ const base = options.baseUrl.replace(/\/+$/, '');
43
+ return async (req) => {
44
+ const params = new URLSearchParams(queryOf(req.query)).toString();
45
+ const url = `${base}${req.path}${params === '' ? '' : `?${params}`}`;
46
+ const headers = { accept: 'application/json', ...options.headers };
47
+ const init = { method: req.method, headers };
48
+ if (req.body !== undefined) {
49
+ headers['content-type'] = 'application/json';
50
+ init.body = JSON.stringify(req.body);
51
+ }
52
+ if (options.credentials !== undefined)
53
+ init.credentials = options.credentials;
54
+ if (req.signal !== undefined)
55
+ init.signal = req.signal;
56
+ if (req.keepalive === true)
57
+ init.keepalive = true;
58
+ const res = await (options.fetch ?? globalThis.fetch)(url, init);
59
+ const headersOut = {};
60
+ res.headers.forEach((value, key) => (headersOut[key.toLowerCase()] = value));
61
+ const text = await res.text();
62
+ let body = text === '' ? null : text;
63
+ if (text !== '' && /\bjson\b/i.test(headersOut['content-type'] ?? '')) {
64
+ try {
65
+ body = JSON.parse(text);
66
+ }
67
+ catch {
68
+ body = text;
69
+ }
70
+ }
71
+ return { status: res.status, headers: headersOut, body };
72
+ };
73
+ }
74
+ //# sourceMappingURL=transport.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transport.js","sourceRoot":"","sources":["../../src/transport.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,91 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { NoticeKind, PlayerNotice } from '@chatwarden/core';
3
+ import type { PlayerError } from './client.js';
4
+ import type { PlayerT, PlayerText } from './i18n.js';
5
+ /** What every component takes besides its own props. */
6
+ export interface PlayerUiProps {
7
+ /** Words: `createT({ ...en, ...yourTranslation }, locale)`. Default: English. */
8
+ t?: PlayerT;
9
+ /** The locale for dates, times and plurals. Default: `en`. */
10
+ locale?: string;
11
+ /** The current time in epoch ms, for "how long is left". Default: `Date.now`. */
12
+ now?: () => number;
13
+ /** An IANA time zone for the times shown (`UTC`, `Europe/Berlin`). Default: the player's own. */
14
+ timeZone?: string;
15
+ /** Forces a theme; without it the player's `prefers-color-scheme` decides. */
16
+ theme?: 'light' | 'dark';
17
+ /** Added to the root element, so a game can theme it from its own stylesheet. */
18
+ className?: string;
19
+ }
20
+ export interface Ui {
21
+ t: PlayerT;
22
+ locale: string;
23
+ now: () => number;
24
+ timeZone: string | undefined;
25
+ }
26
+ export declare function useUi({ t, locale, now, timeZone }: PlayerUiProps): Ui;
27
+ /** What the two components that open a dialog take besides the common props. */
28
+ export interface DialogHostProps {
29
+ /**
30
+ * Draw the dialog inside this element instead of where the component is. The dialog is `position: fixed`, so an ancestor of the
31
+ * component with a `transform`, `filter`, `contain` or `will-change: transform` would make it relative to that ancestor and clip it;
32
+ * pass `document.body` (or a layer of your own) to avoid that. The theme and `className` still apply.
33
+ */
34
+ container?: HTMLElement | null;
35
+ /**
36
+ * Where focus goes when the dialog closes and the element that had focus before it opened is gone. Without it focus is left to the
37
+ * browser, which is the page body.
38
+ */
39
+ returnFocusTo?: () => HTMLElement | null;
40
+ }
41
+ /** Renders `children` where they are, or into `container` when one is given. */
42
+ export declare function Portal({ container, children }: {
43
+ container: HTMLElement | null | undefined;
44
+ children: ReactNode;
45
+ }): import("react").JSX.Element;
46
+ /** The root every component renders: the one class all the stylesheet's rules are scoped under. */
47
+ export declare function Root({ theme, className, children }: {
48
+ theme?: 'light' | 'dark';
49
+ className?: string;
50
+ children: ReactNode;
51
+ }): import("react").JSX.Element;
52
+ /**
53
+ * 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
54
+ * 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
55
+ * always written beside it. A kind with no card (a thank-you for a report) draws nothing.
56
+ */
57
+ export declare function Card({ kind }: {
58
+ kind: string;
59
+ }): import("react").JSX.Element | null;
60
+ /** The plain sentence for a failed call. The server's own message is never shown. */
61
+ export declare function errorText(t: PlayerT, e: PlayerError): string;
62
+ /** A failed load: what could not be loaded, why, and a way to try again. */
63
+ export declare function ErrorBlock({ ui, what, error, onRetry }: {
64
+ ui: Ui;
65
+ what: string;
66
+ error: PlayerError;
67
+ onRetry: () => void;
68
+ }): import("react").JSX.Element;
69
+ /** "Think this is a mistake?", when the adopter gave an address that is safe to link to. */
70
+ export declare function AppealLink({ ui, href }: {
71
+ ui: Ui;
72
+ href: string | undefined;
73
+ }): import("react").JSX.Element | null;
74
+ export declare const titleKey: (kind: NoticeKind | string) => PlayerText;
75
+ export declare const bodyKey: (kind: NoticeKind | string) => PlayerText;
76
+ export declare const kindKey: (kind: NoticeKind | string) => PlayerText;
77
+ /** A sanction a player can contest (a thank-you for a report is not one). */
78
+ export declare const isSanction: (kind: string) => boolean;
79
+ /** The facts of a notice, as a term list: the rule, when it was given (the record only), until when, and the reference code. */
80
+ export declare function Facts({ ui, notice, record }: {
81
+ ui: Ui;
82
+ notice: PlayerNotice;
83
+ record?: boolean;
84
+ }): import("react").JSX.Element | null;
85
+ /** The player's own lines that led to a notice, as text with their day. Never HTML. */
86
+ export declare function Lines({ ui, notice, id }: {
87
+ ui: Ui;
88
+ notice: PlayerNotice;
89
+ id: string;
90
+ }): import("react").JSX.Element | null;
91
+ //# sourceMappingURL=ui.d.ts.map
@@ -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.2",
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.2",
56
+ "@chatwarden/http": "0.1.2",
57
+ "@chatwarden/hub-react": "0.1.2",
58
+ "@chatwarden/store-memory": "0.1.2",
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,117 @@
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
+ /**
15
+ * The controls Tab visits, in order. A radio group is one stop (the checked radio, or the first when none is checked), because the
16
+ * browser moves between its radios with the arrow keys: counting every radio would make "the first control" the wrong one to wrap at.
17
+ */
18
+ function tabStops(box: HTMLElement): HTMLElement[] {
19
+ const all = [...box.querySelectorAll<HTMLElement>(FOCUSABLE)];
20
+ return all.filter((el) => {
21
+ if (!(el instanceof HTMLInputElement) || el.type !== 'radio' || el.name === '') return true;
22
+ const group = all.filter((o): o is HTMLInputElement => o instanceof HTMLInputElement && o.type === 'radio' && o.name === el.name);
23
+ return el === (group.find((r) => r.checked) ?? group[0]);
24
+ });
25
+ }
26
+
27
+ export function Dialog({
28
+ labelledBy,
29
+ initialFocus,
30
+ onEscape,
31
+ returnFocusTo,
32
+ children,
33
+ }: {
34
+ /** The id of the heading that names the window. */
35
+ labelledBy: string;
36
+ /** What takes focus when it opens: the heading. */
37
+ initialFocus: RefObject<HTMLElement | null>;
38
+ /** Called on Escape. Leave it out when the window must not be dismissed. */
39
+ onEscape?: () => void;
40
+ /** Where focus goes on close when the element that had it before is gone. */
41
+ returnFocusTo?: () => HTMLElement | null;
42
+ children: ReactNode;
43
+ }) {
44
+ const box = useRef<HTMLDivElement>(null);
45
+ const fallback = useRef(returnFocusTo);
46
+ fallback.current = returnFocusTo;
47
+ // What had focus before the window opened, read while rendering for the first time: a child's own effect (which moves focus to
48
+ // its heading) runs before this component's effects, so reading it in an effect would find the heading.
49
+ const [before] = useState<Element | null>(() => (typeof document === 'undefined' ? null : document.activeElement));
50
+
51
+ useEffect(() => {
52
+ (initialFocus.current ?? box.current)?.focus();
53
+ const onFocusIn = (e: FocusEvent): void => {
54
+ const target = e.target;
55
+ const inner = box.current;
56
+ if (inner !== null && target instanceof Node && !inner.contains(target)) (initialFocus.current ?? inner).focus();
57
+ };
58
+ document.addEventListener('focusin', onFocusIn);
59
+ return () => {
60
+ // Stop guarding first, so giving focus back is not pulled in again.
61
+ document.removeEventListener('focusin', onFocusIn);
62
+ // Give focus back, unless what had it is gone (the page moved on while the window was open): then to `returnFocusTo()`, if
63
+ // the host gave one, and otherwise to wherever the browser leaves it (the page body).
64
+ if (before instanceof HTMLElement && before.isConnected) before.focus();
65
+ else {
66
+ try {
67
+ fallback.current?.()?.focus();
68
+ } catch {
69
+ // A host callback that throws must not stop the window closing.
70
+ }
71
+ }
72
+ };
73
+ // Once, when the window opens.
74
+ }, []);
75
+
76
+ const onKeyDown = (e: KeyboardEvent<HTMLDivElement>): void => {
77
+ if (e.key === 'Escape') {
78
+ if (onEscape === undefined) return;
79
+ e.preventDefault();
80
+ e.stopPropagation();
81
+ onEscape();
82
+ return;
83
+ }
84
+ if (e.key !== 'Tab' || box.current === null) return;
85
+ const items = tabStops(box.current);
86
+ if (items.length === 0) {
87
+ e.preventDefault();
88
+ return;
89
+ }
90
+ const first = items[0] as HTMLElement;
91
+ const last = items[items.length - 1] as HTMLElement;
92
+ const active = document.activeElement;
93
+ // From the heading (or the box), which is not in the list, Tab goes to the first control and Shift+Tab to the last.
94
+ const inList = items.includes(active as HTMLElement);
95
+ if (e.shiftKey && (active === first || !inList)) {
96
+ e.preventDefault();
97
+ last.focus();
98
+ } else if (!e.shiftKey && (active === last || !inList)) {
99
+ e.preventDefault();
100
+ first.focus();
101
+ }
102
+ };
103
+
104
+ return (
105
+ <div
106
+ className="cw-scrim"
107
+ onMouseDown={(e) => {
108
+ // 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.
109
+ if (e.target === e.currentTarget) e.preventDefault();
110
+ }}
111
+ >
112
+ <div ref={box} className="cw-dialog" role="dialog" aria-modal="true" aria-labelledby={labelledBy} tabIndex={-1} onKeyDown={onKeyDown}>
113
+ {children}
114
+ </div>
115
+ </div>
116
+ );
117
+ }
@@ -0,0 +1,139 @@
1
+ import { useId, useState } from 'react';
2
+ import type { FormEvent, MutableRefObject } from 'react';
3
+ import type { ReportDetailResult } from '@chatwarden/core';
4
+ import { NOTE_MAX } from './constants.js';
5
+ import { PlayerError } from './client.js';
6
+ import type { PlayerText } from './i18n.js';
7
+ import { Picker } from './Picker.js';
8
+ import type { ReasonGroup } from './reporting.js';
9
+ import { errorText } from './ui.js';
10
+ import type { PlayerT } from './i18n.js';
11
+
12
+ /** What the player adds after a one-tap report. Your server calls `cw.reports.addDetail(reportId, reporterSeat, detail)`. */
13
+ export interface ReportFollowUp {
14
+ reason?: string;
15
+ note?: string;
16
+ }
17
+
18
+ /** How the follow-up ended, for the dialog to say: added, or the refusal word (an unknown one reads as a failure). */
19
+ export type FollowUpOutcome = { ok: true } | { ok: false; refused: string };
20
+
21
+ /** A failure to send as an error the components can word: anything that is not already a PlayerError is a dropped connection. */
22
+ const failureOf = (e: unknown): PlayerError =>
23
+ e instanceof PlayerError ? e : new PlayerError({ status: 0, code: 'network', message: 'Could not reach the server.' });
24
+
25
+ /**
26
+ * The optional step after a one-tap report: an "Add a reason" button that opens the reasons (in groups) and a note, and sends them to
27
+ * `onAddDetail` for the report just filed. Nothing is sent when nothing is chosen. One thing on its way at a time, shared with the
28
+ * dialog through `sending` (so Escape and Close wait for it); `onBusy` tells the dialog to dim its Close button.
29
+ */
30
+ export function FollowUp(props: {
31
+ t: PlayerT;
32
+ groups: readonly ReasonGroup[];
33
+ reportId: string;
34
+ onAddDetail: (reportId: string, detail: ReportFollowUp) => Promise<ReportDetailResult>;
35
+ sending: MutableRefObject<boolean>;
36
+ busy: boolean;
37
+ onBusy: (busy: boolean) => void;
38
+ onDone: (outcome: FollowUpOutcome) => void;
39
+ }) {
40
+ const { t, groups, reportId, onAddDetail, sending, busy, onBusy, onDone } = props;
41
+ const name = useId();
42
+ const noteId = useId();
43
+ const missingId = useId();
44
+ const panelId = useId();
45
+ const [open, setOpen] = useState(false);
46
+ const [reason, setReason] = useState<string | null>(null);
47
+ const [note, setNote] = useState('');
48
+ const [missing, setMissing] = useState(false);
49
+ const [failure, setFailure] = useState<PlayerError | null>(null);
50
+
51
+ const trimmed = note.trim();
52
+ const submit = async (e: FormEvent): Promise<void> => {
53
+ e.preventDefault();
54
+ if (sending.current) return;
55
+ if (reason === null && trimmed === '') {
56
+ setMissing(true);
57
+ return;
58
+ }
59
+ sending.current = true;
60
+ onBusy(true);
61
+ setFailure(null);
62
+ try {
63
+ const detail: ReportFollowUp = { ...(reason === null ? {} : { reason }), ...(trimmed === '' ? {} : { note: trimmed }) };
64
+ const raw: unknown = await onAddDetail(reportId, detail);
65
+ // Anything that is not a result object (undefined, null, a string) is not a success and not a crash: it reads as an unknown refusal.
66
+ const answer = typeof raw === 'object' && raw !== null && !Array.isArray(raw) ? (raw as ReportDetailResult) : ({ ok: false, refused: 'unknown' } as unknown as ReportDetailResult);
67
+ onDone(answer.ok === true ? { ok: true } : { ok: false, refused: String((answer as { refused?: unknown }).refused) });
68
+ } catch (error) {
69
+ setFailure(failureOf(error));
70
+ } finally {
71
+ sending.current = false;
72
+ onBusy(false);
73
+ }
74
+ };
75
+
76
+ return (
77
+ <form onSubmit={(e) => void submit(e)} noValidate>
78
+ <button type="button" className="cw-btn cw-disclosure" aria-expanded={open} aria-controls={open ? panelId : undefined} onClick={() => setOpen(!open)}>
79
+ {t('player.report.addReason')}
80
+ </button>
81
+ {open && (
82
+ <div id={panelId} className="cw-panel">
83
+ <p className="cw-hint">{t('player.report.addReasonHint')}</p>
84
+ <Picker
85
+ groups={groups}
86
+ name={name}
87
+ value={reason}
88
+ legend={t('player.report.detail.legend')}
89
+ invalid={missing}
90
+ describedBy={missingId}
91
+ onChange={(id) => {
92
+ setReason(id);
93
+ setMissing(false);
94
+ }}
95
+ />
96
+ {missing && (
97
+ <p id={missingId} className="cw-field-error" role="alert">
98
+ {t('player.report.detail.need')}
99
+ </p>
100
+ )}
101
+ <label className="cw-label" htmlFor={noteId}>
102
+ {t('player.report.note')}
103
+ </label>
104
+ <textarea
105
+ id={noteId}
106
+ className="cw-textarea"
107
+ rows={3}
108
+ maxLength={NOTE_MAX}
109
+ value={note}
110
+ onChange={(e) => {
111
+ setNote(e.target.value);
112
+ setMissing(false);
113
+ }}
114
+ />
115
+ {failure !== null && (
116
+ <p className="cw-error" role="alert">
117
+ {t('player.report.detail.failed')} {errorText(t, failure)}
118
+ </p>
119
+ )}
120
+ <div className="cw-actions">
121
+ <button type="submit" className="cw-btn cw-btn--primary" aria-disabled={busy || undefined} aria-busy={busy || undefined}>
122
+ {t('player.report.detail.send')}
123
+ </button>
124
+ </div>
125
+ </div>
126
+ )}
127
+ </form>
128
+ );
129
+ }
130
+
131
+ /** The plain message for a follow-up refusal; one a newer kit adds gets the general one. */
132
+ export const DETAIL_REFUSED_TEXT: Readonly<Record<string, PlayerText>> = {
133
+ off: 'player.report.detail.refused.off',
134
+ not_found: 'player.report.detail.refused.not_found',
135
+ not_reporter: 'player.report.detail.refused.not_reporter',
136
+ too_late: 'player.report.detail.refused.too_late',
137
+ already_added: 'player.report.detail.refused.already_added',
138
+ reason_unknown: 'player.report.detail.refused.reason_unknown',
139
+ };