@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.
- package/LICENSE +202 -0
- package/README.md +377 -2
- package/dist/src/Dialog.d.ts +13 -0
- package/dist/src/Dialog.d.ts.map +1 -0
- package/dist/src/Dialog.js +94 -0
- package/dist/src/Dialog.js.map +1 -0
- package/dist/src/FollowUp.d.ts +35 -0
- package/dist/src/FollowUp.d.ts.map +1 -0
- package/dist/src/FollowUp.js +69 -0
- package/dist/src/FollowUp.js.map +1 -0
- package/dist/src/MyRecord.d.ts +12 -0
- package/dist/src/MyRecord.d.ts.map +1 -0
- package/dist/src/MyRecord.js +17 -0
- package/dist/src/MyRecord.js.map +1 -0
- package/dist/src/NoticeQueue.d.ts +26 -0
- package/dist/src/NoticeQueue.d.ts.map +1 -0
- package/dist/src/NoticeQueue.js +90 -0
- package/dist/src/NoticeQueue.js.map +1 -0
- package/dist/src/Picker.d.ts +14 -0
- package/dist/src/Picker.d.ts.map +1 -0
- package/dist/src/Picker.js +14 -0
- package/dist/src/Picker.js.map +1 -0
- package/dist/src/ReportDialog.d.ts +50 -0
- package/dist/src/ReportDialog.d.ts.map +1 -0
- package/dist/src/ReportDialog.js +157 -0
- package/dist/src/ReportDialog.js.map +1 -0
- package/dist/src/RestrictedChatLine.d.ts +12 -0
- package/dist/src/RestrictedChatLine.d.ts.map +1 -0
- package/dist/src/RestrictedChatLine.js +19 -0
- package/dist/src/RestrictedChatLine.js.map +1 -0
- package/dist/src/RulesPage.d.ts +10 -0
- package/dist/src/RulesPage.d.ts.map +1 -0
- package/dist/src/RulesPage.js +17 -0
- package/dist/src/RulesPage.js.map +1 -0
- package/dist/src/client.d.ts +51 -0
- package/dist/src/client.d.ts.map +1 -0
- package/dist/src/client.js +98 -0
- package/dist/src/client.js.map +1 -0
- package/dist/src/constants.d.ts +5 -0
- package/dist/src/constants.d.ts.map +1 -0
- package/dist/src/constants.js +5 -0
- package/dist/src/constants.js.map +1 -0
- package/dist/src/format.d.ts +14 -0
- package/dist/src/format.d.ts.map +1 -0
- package/dist/src/format.js +55 -0
- package/dist/src/format.js.map +1 -0
- package/dist/src/hooks.d.ts +15 -0
- package/dist/src/hooks.d.ts.map +1 -0
- package/dist/src/hooks.js +29 -0
- package/dist/src/hooks.js.map +1 -0
- package/dist/src/i18n.d.ts +129 -0
- package/dist/src/i18n.d.ts.map +1 -0
- package/dist/src/i18n.js +167 -0
- package/dist/src/i18n.js.map +1 -0
- package/dist/src/index.d.ts +21 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +11 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/reporting.d.ts +20 -0
- package/dist/src/reporting.d.ts.map +1 -0
- package/dist/src/reporting.js +45 -0
- package/dist/src/reporting.js.map +1 -0
- package/dist/src/safeHref.d.ts +5 -0
- package/dist/src/safeHref.d.ts.map +1 -0
- package/dist/src/safeHref.js +41 -0
- package/dist/src/safeHref.js.map +1 -0
- package/dist/src/transport.d.ts +69 -0
- package/dist/src/transport.d.ts.map +1 -0
- package/dist/src/transport.js +74 -0
- package/dist/src/transport.js.map +1 -0
- package/dist/src/ui.d.ts +91 -0
- package/dist/src/ui.d.ts.map +1 -0
- package/dist/src/ui.js +116 -0
- package/dist/src/ui.js.map +1 -0
- package/package.json +73 -4
- package/src/Dialog.tsx +117 -0
- package/src/FollowUp.tsx +139 -0
- package/src/MyRecord.tsx +60 -0
- package/src/NoticeQueue.tsx +203 -0
- package/src/Picker.tsx +60 -0
- package/src/ReportDialog.tsx +304 -0
- package/src/RestrictedChatLine.tsx +42 -0
- package/src/RulesPage.tsx +49 -0
- package/src/client.ts +132 -0
- package/src/constants.ts +5 -0
- package/src/format.ts +53 -0
- package/src/hooks.ts +41 -0
- package/src/i18n.ts +187 -0
- package/src/index.ts +32 -0
- package/src/reporting.ts +51 -0
- package/src/safeHref.ts +36 -0
- package/src/styles.css +516 -0
- package/src/transport.ts +134 -0
- package/src/ui.tsx +199 -0
package/src/MyRecord.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/Picker.tsx
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { useId } from 'react';
|
|
2
|
+
import type { RefObject } from 'react';
|
|
3
|
+
import type { ReasonGroup, ReasonItem } from './reporting.js';
|
|
4
|
+
|
|
5
|
+
/** The radios, by group. One radio group (one name) across the groups, so the arrow keys walk all of them and Tab stops once. */
|
|
6
|
+
export function Picker(props: {
|
|
7
|
+
groups: readonly ReasonGroup[];
|
|
8
|
+
name: string;
|
|
9
|
+
value: string | null;
|
|
10
|
+
onChange: (id: string) => void;
|
|
11
|
+
legend: string;
|
|
12
|
+
invalid?: boolean;
|
|
13
|
+
describedBy?: string | undefined;
|
|
14
|
+
fieldset?: RefObject<HTMLFieldSetElement | null>;
|
|
15
|
+
}) {
|
|
16
|
+
const { groups, name, value, onChange, legend, invalid, describedBy, fieldset } = props;
|
|
17
|
+
const base = useId();
|
|
18
|
+
const choice = (r: ReasonItem) => {
|
|
19
|
+
const label = `${base}-${r.id}-l`;
|
|
20
|
+
const hint = `${base}-${r.id}-h`;
|
|
21
|
+
return (
|
|
22
|
+
<label key={r.id} className="cw-choice">
|
|
23
|
+
<input
|
|
24
|
+
type="radio"
|
|
25
|
+
name={name}
|
|
26
|
+
value={r.id}
|
|
27
|
+
checked={value === r.id}
|
|
28
|
+
aria-labelledby={label}
|
|
29
|
+
aria-describedby={r.hint === '' ? undefined : hint}
|
|
30
|
+
onChange={() => onChange(r.id)}
|
|
31
|
+
/>
|
|
32
|
+
<span className="cw-choice-text">
|
|
33
|
+
<span id={label}>{r.label}</span>
|
|
34
|
+
{r.hint !== '' && (
|
|
35
|
+
<span id={hint} className="cw-choice-hint">
|
|
36
|
+
{r.hint}
|
|
37
|
+
</span>
|
|
38
|
+
)}
|
|
39
|
+
</span>
|
|
40
|
+
</label>
|
|
41
|
+
);
|
|
42
|
+
};
|
|
43
|
+
return (
|
|
44
|
+
<fieldset ref={fieldset} className="cw-fieldset" role="radiogroup" aria-invalid={invalid || undefined} aria-describedby={invalid ? describedBy : undefined}>
|
|
45
|
+
<legend className="cw-legend">{legend}</legend>
|
|
46
|
+
{groups.map((g, i) =>
|
|
47
|
+
g.name === '' ? (
|
|
48
|
+
<div key={`g${i}`} className="cw-group">
|
|
49
|
+
{g.items.map(choice)}
|
|
50
|
+
</div>
|
|
51
|
+
) : (
|
|
52
|
+
<fieldset key={`g${i}`} className="cw-fieldset cw-group">
|
|
53
|
+
<legend className="cw-group-legend">{g.name}</legend>
|
|
54
|
+
{g.items.map(choice)}
|
|
55
|
+
</fieldset>
|
|
56
|
+
),
|
|
57
|
+
)}
|
|
58
|
+
</fieldset>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
2
|
+
import type { FormEvent } from 'react';
|
|
3
|
+
import type { PlayerReporting, ReportDetailResult, ReportFlow, ReportRefusal, ReportResult } from '@chatwarden/core';
|
|
4
|
+
import { NOTE_MAX } from './constants.js';
|
|
5
|
+
import { PlayerError } from './client.js';
|
|
6
|
+
import { Dialog } from './Dialog.js';
|
|
7
|
+
import { DETAIL_REFUSED_TEXT, FollowUp } from './FollowUp.js';
|
|
8
|
+
import type { FollowUpOutcome, ReportFollowUp } from './FollowUp.js';
|
|
9
|
+
import { Picker } from './Picker.js';
|
|
10
|
+
import type { PlayerText } from './i18n.js';
|
|
11
|
+
import { groupReasons, readReporting } from './reporting.js';
|
|
12
|
+
import type { ReasonItem } from './reporting.js';
|
|
13
|
+
import { errorText, Portal, Root, useUi } from './ui.js';
|
|
14
|
+
import type { DialogHostProps, PlayerUiProps } from './ui.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What the player filled in. Your server adds the room and the seats and calls `cw.reports.submit({ ...draft, roomId, reporterSeat,
|
|
18
|
+
* targetSeat })`. The dialog sends `reason` (an id from the studio's `reporting` list, or nothing, as in one tap). A reason is only a
|
|
19
|
+
* hint: the moderators decide what a report really is.
|
|
20
|
+
*/
|
|
21
|
+
export interface ReportDraft {
|
|
22
|
+
/** The reason the player chose: an id from `reporting.reasons`. Left out when none was chosen. */
|
|
23
|
+
reason?: string;
|
|
24
|
+
/** The note, trimmed; left out when blank. */
|
|
25
|
+
note?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type { ReportFollowUp } from './FollowUp.js';
|
|
29
|
+
|
|
30
|
+
export interface ReportDialogProps extends PlayerUiProps, DialogHostProps {
|
|
31
|
+
/**
|
|
32
|
+
* Sends the report: your server calls `cw.reports.submit({ ...draft, roomId, reporterSeat, targetSeat })` and returns its
|
|
33
|
+
* `ReportResult`. A refusal is a result, not an error; reject only when no answer arrived.
|
|
34
|
+
*/
|
|
35
|
+
onSubmit: (draft: ReportDraft) => Promise<ReportResult>;
|
|
36
|
+
/**
|
|
37
|
+
* The optional follow-up after a one-tap report: your server calls `cw.reports.addDetail(reportId, reporterSeat, detail)` (the seat
|
|
38
|
+
* from its own session, never from the page) and returns its `ReportDetailResult`. Without it the "Add a reason" step is not shown.
|
|
39
|
+
*/
|
|
40
|
+
onAddDetail?: (reportId: string, detail: ReportFollowUp) => Promise<ReportDetailResult>;
|
|
41
|
+
/** Called on Escape, Cancel and Close. Unmount the dialog in answer. */
|
|
42
|
+
onClose: () => void;
|
|
43
|
+
/**
|
|
44
|
+
* How reporting works here: `(await createPlayerClient.rules()).reporting`, the `reporting` part of `GET /rules`. It sets the flow
|
|
45
|
+
* (one tap, pick a reason first, reason required), the reasons (grouped, with hints) and the thank-you. Without it the dialog is
|
|
46
|
+
* one tap with the dialog's own words, the same as when `GET /rules` cannot be loaded.
|
|
47
|
+
*/
|
|
48
|
+
reporting?: PlayerReporting;
|
|
49
|
+
/** Who is being reported, as the player knows them (a handle). Shown as text in the heading. */
|
|
50
|
+
targetName?: string;
|
|
51
|
+
/** The message being reported. Shown as quoted text (cut if very long) so the player sees what they are reporting. */
|
|
52
|
+
line?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// One plain message for each refusal the kit has. Anything else (a refusal a newer kit adds) gets the last one.
|
|
56
|
+
const REFUSED_TEXT: Readonly<Record<ReportRefusal, PlayerText>> = {
|
|
57
|
+
duplicate: 'player.report.refused.duplicate',
|
|
58
|
+
rate: 'player.report.refused.rate',
|
|
59
|
+
self: 'player.report.refused.self',
|
|
60
|
+
not_player: 'player.report.refused.not_player',
|
|
61
|
+
off: 'player.report.refused.off',
|
|
62
|
+
suspended: 'player.report.refused.suspended',
|
|
63
|
+
reason_required: 'player.report.refused.reason_required',
|
|
64
|
+
reason_unknown: 'player.report.refused.reason_unknown',
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** How much of the reported message is quoted on the screen. */
|
|
68
|
+
const LINE_MAX = 280;
|
|
69
|
+
|
|
70
|
+
/** A failure to send as an error the components can word: anything that is not already a PlayerError is a dropped connection. */
|
|
71
|
+
const failureOf = (e: unknown): PlayerError =>
|
|
72
|
+
e instanceof PlayerError ? e : new PlayerError({ status: 0, code: 'network', message: 'Could not reach the server.' });
|
|
73
|
+
|
|
74
|
+
/** 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. */
|
|
75
|
+
const resultOf = <T,>(answer: unknown, unknown: T): T => (typeof answer === 'object' && answer !== null && !Array.isArray(answer) ? (answer as T) : unknown);
|
|
76
|
+
|
|
77
|
+
const quoted = (line: string | undefined): string => {
|
|
78
|
+
const plain = (line ?? '').replace(/\s+/g, ' ').trim();
|
|
79
|
+
return plain.length > LINE_MAX ? `${plain.slice(0, LINE_MAX).trimEnd()}…` : plain;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** The screen to draw: the flow, the reasons as the dialog will list them, and the thank-you. */
|
|
83
|
+
interface Screen {
|
|
84
|
+
flow: ReportFlow;
|
|
85
|
+
reasons: ReasonItem[];
|
|
86
|
+
thanks: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A dialog for reporting a message or a player. How it works is the studio's setting (`reporting`, from `GET /rules`):
|
|
91
|
+
* - `one_tap` (the default): one step, "Report this message?" and a Report button. The report is filed at once and the thank-you
|
|
92
|
+
* shown; an optional "Add a reason" step then attaches a reason and a note to the same report (`onAddDetail`).
|
|
93
|
+
* - `reason_first`: the reasons in groups, then an optional note; a reason can be skipped.
|
|
94
|
+
* - `reason_required`: the same without the skip.
|
|
95
|
+
* The reason is only a hint for the moderators. Every refusal the kit can give has its own plain message. Render it when the player
|
|
96
|
+
* chooses to report, and unmount it in `onClose`.
|
|
97
|
+
*/
|
|
98
|
+
export function ReportDialog(props: ReportDialogProps) {
|
|
99
|
+
const { onSubmit, onAddDetail, onClose, targetName, line, theme, className, container, returnFocusTo } = props;
|
|
100
|
+
const ui = useUi(props);
|
|
101
|
+
const { t } = ui;
|
|
102
|
+
const heading = useRef<HTMLHeadingElement>(null);
|
|
103
|
+
const picker = useRef<HTMLFieldSetElement>(null);
|
|
104
|
+
// One thing on its way at a time, report or follow-up: a second click, or a second Enter, does nothing.
|
|
105
|
+
const sending = useRef(false);
|
|
106
|
+
const titleId = useId();
|
|
107
|
+
const groupName = useId();
|
|
108
|
+
const noteId = useId();
|
|
109
|
+
const hintId = useId();
|
|
110
|
+
const missingId = useId();
|
|
111
|
+
const [reason, setReason] = useState<string | null>(null);
|
|
112
|
+
const [note, setNote] = useState('');
|
|
113
|
+
const [missing, setMissing] = useState(false);
|
|
114
|
+
const [busy, setBusy] = useState(false);
|
|
115
|
+
const [failure, setFailure] = useState<PlayerError | null>(null);
|
|
116
|
+
const [result, setResult] = useState<ReportResult | null>(null);
|
|
117
|
+
// How the follow-up after a one-tap report ended, and whether its time is up (the kit takes one until `detailUntil`).
|
|
118
|
+
const [dDone, setDDone] = useState<FollowUpOutcome | null>(null);
|
|
119
|
+
const [expired, setExpired] = useState(false);
|
|
120
|
+
|
|
121
|
+
const screen: Screen = (() => {
|
|
122
|
+
// No `reporting` (or something that is not the payload): one tap with the dialog's own words, as when `GET /rules` cannot be loaded.
|
|
123
|
+
const read = readReporting(props.reporting) ?? { flow: 'one_tap' as const, reasons: [], thanks: '' };
|
|
124
|
+
// A flow that asks for a reason has nothing to ask with when the list is empty: it is one tap.
|
|
125
|
+
return { flow: read.reasons.length === 0 ? 'one_tap' : read.flow, reasons: read.reasons, thanks: read.thanks.trim() };
|
|
126
|
+
})();
|
|
127
|
+
const { flow } = screen;
|
|
128
|
+
const groups = groupReasons(screen.reasons);
|
|
129
|
+
const chosen = screen.reasons.find((r) => r.id === reason);
|
|
130
|
+
// The note: when the chosen reason asks for it.
|
|
131
|
+
const showNote = chosen?.asksNote === true;
|
|
132
|
+
|
|
133
|
+
// The form is gone once there is an answer: focus goes back to the heading, so the answer is read and focus is not lost.
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
if (result !== null) heading.current?.focus();
|
|
136
|
+
}, [result]);
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (dDone !== null) heading.current?.focus();
|
|
139
|
+
}, [dDone]);
|
|
140
|
+
// The follow-up stops being offered when the kit would no longer take it.
|
|
141
|
+
const until = result?.ok === true && typeof result.detailUntil === 'number' && Number.isFinite(result.detailUntil) ? result.detailUntil : null;
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (until === null) return undefined;
|
|
144
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
145
|
+
// Looks at the clock again when the time comes, so a clock that has not moved does not end the offer early.
|
|
146
|
+
const check = (): void => {
|
|
147
|
+
const left = until - ui.now();
|
|
148
|
+
if (left < 0) setExpired(true);
|
|
149
|
+
else timer = setTimeout(check, Math.min(Math.max(left + 1, 50), 2_000_000_000));
|
|
150
|
+
};
|
|
151
|
+
check();
|
|
152
|
+
return () => clearTimeout(timer);
|
|
153
|
+
}, [until]);
|
|
154
|
+
|
|
155
|
+
const send = async (draft: ReportDraft): Promise<void> => {
|
|
156
|
+
sending.current = true;
|
|
157
|
+
setBusy(true);
|
|
158
|
+
setFailure(null);
|
|
159
|
+
try {
|
|
160
|
+
setResult(resultOf<ReportResult>(await onSubmit(draft), { ok: false, refused: 'unknown' } as unknown as ReportResult));
|
|
161
|
+
} catch (error) {
|
|
162
|
+
setFailure(failureOf(error));
|
|
163
|
+
} finally {
|
|
164
|
+
sending.current = false;
|
|
165
|
+
setBusy(false);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const trimmed = note.trim();
|
|
170
|
+
const submit = (e: FormEvent): void => {
|
|
171
|
+
e.preventDefault();
|
|
172
|
+
if (sending.current) return;
|
|
173
|
+
if (flow !== 'one_tap' && reason === null) {
|
|
174
|
+
setMissing(true);
|
|
175
|
+
picker.current?.querySelector<HTMLElement>('input')?.focus();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const draft: ReportDraft = flow === 'one_tap' ? {} : { reason: reason as string };
|
|
179
|
+
if (showNote && trimmed !== '') draft.note = trimmed;
|
|
180
|
+
void send(draft);
|
|
181
|
+
};
|
|
182
|
+
const skip = (): void => {
|
|
183
|
+
if (sending.current) return;
|
|
184
|
+
void send(showNote && trimmed !== '' ? { note: trimmed } : {});
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const reportId = result?.ok === true && typeof result.reportId === 'string' && result.reportId !== '' ? result.reportId : null;
|
|
188
|
+
const quote = quoted(line);
|
|
189
|
+
const oneTapTitle = quote !== '' ? t('player.report.titleOneTap') : targetName === undefined ? t('player.report.titleOneTapPlayer') : t('player.report.titleOneTapNamed', { name: targetName });
|
|
190
|
+
const title = flow === 'one_tap' ? oneTapTitle : targetName === undefined ? t('player.report.title') : t('player.report.titleNamed', { name: targetName });
|
|
191
|
+
const taken = result?.ok === true;
|
|
192
|
+
const refusal = result !== null && !taken ? result : null;
|
|
193
|
+
// After an answer the heading says what happened (a question would be stale), and focus is on it.
|
|
194
|
+
const shownTitle = result === null ? title : taken ? t('player.report.titleSent') : t('player.report.titleRefused');
|
|
195
|
+
const remaining = taken && typeof result.remaining === 'number' && Number.isFinite(result.remaining) && result.remaining >= 0 ? result.remaining : null;
|
|
196
|
+
const refusedKey = (r: unknown): PlayerText => {
|
|
197
|
+
const refused = (r as { refused?: unknown }).refused;
|
|
198
|
+
return typeof refused === 'string' && Object.hasOwn(REFUSED_TEXT, refused) ? REFUSED_TEXT[refused as ReportRefusal] : 'player.report.refused.unknown';
|
|
199
|
+
};
|
|
200
|
+
const detailRefusedKey = (refused: string): PlayerText => (Object.hasOwn(DETAIL_REFUSED_TEXT, refused) ? (DETAIL_REFUSED_TEXT[refused] as PlayerText) : 'player.report.detail.failed');
|
|
201
|
+
const detailUntil = taken && typeof result.detailUntil === 'number' && Number.isFinite(result.detailUntil) ? result.detailUntil : null;
|
|
202
|
+
const timeUp = expired || (detailUntil !== null && ui.now() > detailUntil);
|
|
203
|
+
const canFollow = taken && flow === 'one_tap' && reportId !== null && onAddDetail !== undefined && screen.reasons.length > 0 && !timeUp;
|
|
204
|
+
const needText: PlayerText = flow === 'reason_first' ? 'player.report.needReasonOrSkip' : 'player.report.needReason';
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<Portal container={container}>
|
|
208
|
+
<Root theme={theme} className={className}>
|
|
209
|
+
<Dialog labelledBy={titleId} initialFocus={heading} onEscape={() => !sending.current && onClose()} {...(returnFocusTo === undefined ? {} : { returnFocusTo })}>
|
|
210
|
+
<h2 id={titleId} ref={heading} className="cw-title" tabIndex={-1}>
|
|
211
|
+
{shownTitle}
|
|
212
|
+
</h2>
|
|
213
|
+
{/* Always there, so what is written into it is announced: the thank-you, and "Added. Thanks.". */}
|
|
214
|
+
<div role="status" className="cw-live">
|
|
215
|
+
{taken && (
|
|
216
|
+
<p className="cw-result">
|
|
217
|
+
{screen.thanks === '' ? t('player.report.thanks') : screen.thanks}
|
|
218
|
+
{remaining !== null && ` ${t('player.report.remaining', { count: remaining })}`}
|
|
219
|
+
</p>
|
|
220
|
+
)}
|
|
221
|
+
{dDone?.ok === true && <p className="cw-result">{t('player.report.detail.added')}</p>}
|
|
222
|
+
</div>
|
|
223
|
+
{result === null ? (
|
|
224
|
+
<form onSubmit={submit} noValidate>
|
|
225
|
+
<p className="cw-body">{flow === 'one_tap' ? t('player.report.oneTap.intro') : t('player.report.intro')}</p>
|
|
226
|
+
{quote !== '' && <blockquote className="cw-quote">{t('player.report.quote', { line: quote })}</blockquote>}
|
|
227
|
+
{flow !== 'one_tap' && (
|
|
228
|
+
<Picker
|
|
229
|
+
groups={groups}
|
|
230
|
+
name={groupName}
|
|
231
|
+
value={reason}
|
|
232
|
+
legend={t('player.report.legend')}
|
|
233
|
+
invalid={missing}
|
|
234
|
+
describedBy={missingId}
|
|
235
|
+
fieldset={picker}
|
|
236
|
+
onChange={(id) => {
|
|
237
|
+
setReason(id);
|
|
238
|
+
setMissing(false);
|
|
239
|
+
}}
|
|
240
|
+
/>
|
|
241
|
+
)}
|
|
242
|
+
{missing && (
|
|
243
|
+
<p id={missingId} className="cw-field-error" role="alert">
|
|
244
|
+
{t(needText)}
|
|
245
|
+
</p>
|
|
246
|
+
)}
|
|
247
|
+
{showNote && (
|
|
248
|
+
<>
|
|
249
|
+
<label className="cw-label" htmlFor={noteId}>
|
|
250
|
+
{t('player.report.note')}
|
|
251
|
+
</label>
|
|
252
|
+
<textarea id={noteId} className="cw-textarea" rows={4} maxLength={NOTE_MAX} value={note} aria-describedby={hintId} onChange={(e) => setNote(e.target.value)} />
|
|
253
|
+
<p id={hintId} className="cw-hint">
|
|
254
|
+
{t('player.report.noteHint', { max: NOTE_MAX })}
|
|
255
|
+
</p>
|
|
256
|
+
</>
|
|
257
|
+
)}
|
|
258
|
+
{failure !== null && (
|
|
259
|
+
<p className="cw-error" role="alert">
|
|
260
|
+
{t('player.report.failed')} {errorText(t, failure)}
|
|
261
|
+
</p>
|
|
262
|
+
)}
|
|
263
|
+
<div className="cw-actions">
|
|
264
|
+
<button type="submit" className="cw-btn cw-btn--primary" aria-disabled={busy || undefined} aria-busy={busy || undefined}>
|
|
265
|
+
{flow === 'one_tap' ? t('player.report.oneTap.send') : t('player.report.send')}
|
|
266
|
+
</button>
|
|
267
|
+
{flow === 'reason_first' && (
|
|
268
|
+
<button type="button" className="cw-btn" aria-disabled={busy || undefined} aria-busy={busy || undefined} onClick={skip}>
|
|
269
|
+
{t('player.report.skip')}
|
|
270
|
+
</button>
|
|
271
|
+
)}
|
|
272
|
+
{/* Closing while a report is on its way would lose the answer: busy, like Escape. */}
|
|
273
|
+
<button type="button" className="cw-btn" aria-disabled={busy || undefined} onClick={() => !sending.current && onClose()}>
|
|
274
|
+
{t('player.report.cancel')}
|
|
275
|
+
</button>
|
|
276
|
+
</div>
|
|
277
|
+
</form>
|
|
278
|
+
) : (
|
|
279
|
+
<>
|
|
280
|
+
{!taken && (
|
|
281
|
+
<p className="cw-error" role="alert">
|
|
282
|
+
{t(refusedKey(refusal))}
|
|
283
|
+
</p>
|
|
284
|
+
)}
|
|
285
|
+
{canFollow && dDone === null && (
|
|
286
|
+
<FollowUp t={t} groups={groups} reportId={reportId} onAddDetail={onAddDetail} sending={sending} busy={busy} onBusy={setBusy} onDone={setDDone} />
|
|
287
|
+
)}
|
|
288
|
+
{dDone !== null && !dDone.ok && (
|
|
289
|
+
<p className="cw-error" role="alert">
|
|
290
|
+
{t(detailRefusedKey(dDone.refused))}
|
|
291
|
+
</p>
|
|
292
|
+
)}
|
|
293
|
+
<div className="cw-actions">
|
|
294
|
+
<button type="button" className="cw-btn cw-btn--primary" aria-disabled={busy || undefined} onClick={() => !sending.current && onClose()}>
|
|
295
|
+
{t('player.report.close')}
|
|
296
|
+
</button>
|
|
297
|
+
</div>
|
|
298
|
+
</>
|
|
299
|
+
)}
|
|
300
|
+
</Dialog>
|
|
301
|
+
</Root>
|
|
302
|
+
</Portal>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { timeLeft, untilText } from './format.js';
|
|
2
|
+
import { Card, Root, useUi } from './ui.js';
|
|
3
|
+
import type { PlayerUiProps } from './ui.js';
|
|
4
|
+
|
|
5
|
+
export interface RestrictedChatLineProps extends PlayerUiProps {
|
|
6
|
+
/** When the restriction ends (epoch ms), or `null` for no end. From `cw.chat.restricted(...)`. */
|
|
7
|
+
until: number | null;
|
|
8
|
+
/** `true` when a moderator turned the chat off, `false` when the automatic filter did (kind `auto`). */
|
|
9
|
+
byModerator: boolean;
|
|
10
|
+
/** The reference code to quote to a moderator, when you have one. */
|
|
11
|
+
refCode?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** One line for the place a chat box would be, saying why the player cannot type, until when, and who did it. */
|
|
15
|
+
export function RestrictedChatLine(props: RestrictedChatLineProps) {
|
|
16
|
+
const { until, byModerator, refCode, theme, className } = props;
|
|
17
|
+
const ui = useUi(props);
|
|
18
|
+
const { t, locale, timeZone } = ui;
|
|
19
|
+
const ends = typeof until === 'number' && Number.isFinite(until);
|
|
20
|
+
const when = ends ? untilText(until, locale, timeZone) : '';
|
|
21
|
+
const left = ends ? timeLeft(until, ui.now(), locale) : null;
|
|
22
|
+
const text = !ends
|
|
23
|
+
? t(byModerator ? 'player.restricted.moderator.noEnd' : 'player.restricted.filter.noEnd')
|
|
24
|
+
: left === null
|
|
25
|
+
? t('player.restricted.ended', { when })
|
|
26
|
+
: t(byModerator ? 'player.restricted.moderator.until' : 'player.restricted.filter.until', { when, left });
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Root theme={theme} className={className}>
|
|
30
|
+
<p className="cw-restricted" role="status">
|
|
31
|
+
<Card kind="chat" />
|
|
32
|
+
<span className="cw-text">{text}</span>
|
|
33
|
+
{refCode !== undefined && (
|
|
34
|
+
<>
|
|
35
|
+
{' '}
|
|
36
|
+
<span className="cw-ref">{t('player.restricted.ref', { code: refCode })}</span>
|
|
37
|
+
</>
|
|
38
|
+
)}
|
|
39
|
+
</p>
|
|
40
|
+
</Root>
|
|
41
|
+
);
|
|
42
|
+
}
|