@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.
- package/LICENSE +202 -0
- package/README.md +401 -2
- package/dist/src/Dialog.d.ts +13 -0
- package/dist/src/Dialog.d.ts.map +1 -0
- package/dist/src/Dialog.js +83 -0
- package/dist/src/Dialog.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/ReportDialog.d.ts +64 -0
- package/dist/src/ReportDialog.d.ts.map +1 -0
- package/dist/src/ReportDialog.js +146 -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 +153 -0
- package/dist/src/i18n.d.ts.map +1 -0
- package/dist/src/i18n.js +191 -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 +107 -0
- package/src/MyRecord.tsx +60 -0
- package/src/NoticeQueue.tsx +203 -0
- package/src/ReportDialog.tsx +319 -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 +211 -0
- package/src/index.ts +32 -0
- package/src/reporting.ts +51 -0
- package/src/safeHref.ts +36 -0
- package/src/styles.css +500 -0
- package/src/transport.ts +134 -0
- package/src/ui.tsx +199 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState } from 'react';
|
|
2
|
+
import type { FormEvent, ReactNode } from 'react';
|
|
3
|
+
import type { PlayerReporting, ReportDetailResult, 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 type { PlayerText } from './i18n.js';
|
|
8
|
+
import { groupReasons, readReporting } from './reporting.js';
|
|
9
|
+
import { errorText, Portal, Root, useUi } from './ui.js';
|
|
10
|
+
import type { DialogHostProps, PlayerUiProps } from './ui.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* What the player filled in. Your server adds the room and the seats and calls `cw.reports.submit({ ...draft, roomId, reporterSeat,
|
|
14
|
+
* targetSeat })`. The dialog sends `reason` (an id from the studio's `reporting` list, or nothing) and `note` in the one report. A
|
|
15
|
+
* reason is only a hint: the moderators decide what a report really is.
|
|
16
|
+
*/
|
|
17
|
+
export interface ReportDraft {
|
|
18
|
+
/** The reason the player chose: an id from `reporting.reasons`. Left out when none was chosen. */
|
|
19
|
+
reason?: string;
|
|
20
|
+
/** The note, trimmed; left out when blank. */
|
|
21
|
+
note?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A reason and a note added to a report after it was filed (`cw.reports.addDetail`). The dialog no longer has a follow-up step (the
|
|
26
|
+
* reason and the note go in the one report), so this is only the shape a follow-up of your own may take.
|
|
27
|
+
*/
|
|
28
|
+
export interface ReportFollowUp {
|
|
29
|
+
reason?: string;
|
|
30
|
+
note?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ReportDialogProps extends PlayerUiProps, DialogHostProps {
|
|
34
|
+
/**
|
|
35
|
+
* Sends the report: your server calls `cw.reports.submit({ ...draft, roomId, reporterSeat, targetSeat })` and returns its
|
|
36
|
+
* `ReportResult`. A refusal is a result, not an error; reject only when no answer arrived.
|
|
37
|
+
*/
|
|
38
|
+
onSubmit: (draft: ReportDraft) => Promise<ReportResult>;
|
|
39
|
+
/**
|
|
40
|
+
* @deprecated The dialog is one step now: the reason and the note go in the single `onSubmit`, so this is accepted but never
|
|
41
|
+
* called. Remove it. (`cw.reports.addDetail` is still there for a follow-up of your own.)
|
|
42
|
+
*/
|
|
43
|
+
onAddDetail?: (reportId: string, detail: ReportFollowUp) => Promise<ReportDetailResult>;
|
|
44
|
+
/** Called on Escape, Cancel and Close. Unmount the dialog in answer. */
|
|
45
|
+
onClose: () => void;
|
|
46
|
+
/**
|
|
47
|
+
* How reporting works here: `(await createPlayerClient.rules()).reporting`, the `reporting` part of `GET /rules`. It gives the
|
|
48
|
+
* reasons (grouped, with hints), whether a reason is required, and the thank-you. Without it the dialog has no reasons to offer:
|
|
49
|
+
* one press files the report, with the dialog's own words, the same as when `GET /rules` cannot be loaded.
|
|
50
|
+
*/
|
|
51
|
+
reporting?: PlayerReporting;
|
|
52
|
+
/** Who is being reported, as the player knows them (a handle). Shown as text in the heading. */
|
|
53
|
+
targetName?: string;
|
|
54
|
+
/** The message being reported. Shown as quoted text (cut if very long) so the player sees what they are reporting. */
|
|
55
|
+
line?: string;
|
|
56
|
+
/**
|
|
57
|
+
* @experimental Your own controls, placed inside the form under the reason and the note and above Report and Cancel (for example
|
|
58
|
+
* an "Also mute this player" checkbox and a line about false reports). You own their state; the dialog only places them, adds no
|
|
59
|
+
* styling beyond the spacing, and keeps their focusable controls in its Tab order (reason, note, yours, Report, Cancel) and its
|
|
60
|
+
* focus trap. They are not shown once the report is sent. They sit inside the dialog's form, so give your buttons `type="button"`:
|
|
61
|
+
* a plain button, or Enter in a text field of yours, sends the report.
|
|
62
|
+
*/
|
|
63
|
+
extra?: ReactNode;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// One plain message for each refusal the kit has. Anything else (a refusal a newer kit adds) gets the last one.
|
|
67
|
+
const REFUSED_TEXT: Readonly<Record<ReportRefusal, PlayerText>> = {
|
|
68
|
+
duplicate: 'player.report.refused.duplicate',
|
|
69
|
+
rate: 'player.report.refused.rate',
|
|
70
|
+
self: 'player.report.refused.self',
|
|
71
|
+
not_player: 'player.report.refused.not_player',
|
|
72
|
+
off: 'player.report.refused.off',
|
|
73
|
+
suspended: 'player.report.refused.suspended',
|
|
74
|
+
reason_required: 'player.report.refused.reason_required',
|
|
75
|
+
reason_unknown: 'player.report.refused.reason_unknown',
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** How much of the reported message is quoted on the screen. */
|
|
79
|
+
const LINE_MAX = 280;
|
|
80
|
+
|
|
81
|
+
/** A failure to send as an error the components can word: anything that is not already a PlayerError is a dropped connection. */
|
|
82
|
+
const failureOf = (e: unknown): PlayerError =>
|
|
83
|
+
e instanceof PlayerError ? e : new PlayerError({ status: 0, code: 'network', message: 'Could not reach the server.' });
|
|
84
|
+
|
|
85
|
+
/** 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. */
|
|
86
|
+
const resultOf = <T,>(answer: unknown, unknown: T): T => (typeof answer === 'object' && answer !== null && !Array.isArray(answer) ? (answer as T) : unknown);
|
|
87
|
+
|
|
88
|
+
const quoted = (line: string | undefined): string => {
|
|
89
|
+
const plain = (line ?? '').replace(/\s+/g, ' ').trim();
|
|
90
|
+
return plain.length > LINE_MAX ? `${plain.slice(0, LINE_MAX).trimEnd()}…` : plain;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* A dialog for reporting a message or a player, in one step: "Report this message?", the quoted line, an optional "Why?" (one
|
|
95
|
+
* select, the reasons in groups) and a Report button. A note is behind "Add a note", or shown when the chosen reason asks for one.
|
|
96
|
+
* Report sends the reason and the note together (`onSubmit`); the dialog then says "Report sent" and the thank-you. The studio's
|
|
97
|
+
* `reporting` setting decides whether the reason is required (`reason_required`: the select is required, and Report without a
|
|
98
|
+
* reason asks for one and sends nothing), what the reasons are, and the thank-you. The reason is only a hint for the moderators.
|
|
99
|
+
* Every refusal the kit can give has its own plain message. Render it when the player chooses to report, and unmount it in `onClose`.
|
|
100
|
+
*/
|
|
101
|
+
export function ReportDialog(props: ReportDialogProps) {
|
|
102
|
+
const { onSubmit, onClose, targetName, line, theme, className, container, returnFocusTo, extra } = props;
|
|
103
|
+
const ui = useUi(props);
|
|
104
|
+
const { t } = ui;
|
|
105
|
+
const heading = useRef<HTMLHeadingElement>(null);
|
|
106
|
+
const select = useRef<HTMLSelectElement>(null);
|
|
107
|
+
const noteField = useRef<HTMLTextAreaElement>(null);
|
|
108
|
+
// One report on its way at a time: a second click, or a second Enter, does nothing.
|
|
109
|
+
const sending = useRef(false);
|
|
110
|
+
const titleId = useId();
|
|
111
|
+
const reasonId = useId();
|
|
112
|
+
const noteId = useId();
|
|
113
|
+
const noteHintId = useId();
|
|
114
|
+
const reasonHintId = useId();
|
|
115
|
+
const missingId = useId();
|
|
116
|
+
const [reason, setReason] = useState('');
|
|
117
|
+
const [note, setNote] = useState('');
|
|
118
|
+
// The note is shown once it was asked for (the "Add a note" button, or a reason that asks for one) and then stays.
|
|
119
|
+
const [noteOpen, setNoteOpen] = useState(false);
|
|
120
|
+
const [focusNote, setFocusNote] = useState(false);
|
|
121
|
+
const [missing, setMissing] = useState(false);
|
|
122
|
+
const [busy, setBusy] = useState(false);
|
|
123
|
+
const [failure, setFailure] = useState<PlayerError | null>(null);
|
|
124
|
+
const [result, setResult] = useState<ReportResult | null>(null);
|
|
125
|
+
|
|
126
|
+
// No `reporting` (or something that is not the payload): no reasons to offer, as when `GET /rules` cannot be loaded.
|
|
127
|
+
const read = readReporting(props.reporting) ?? { flow: 'one_tap' as const, reasons: [], thanks: '' };
|
|
128
|
+
const thanks = read.thanks.trim();
|
|
129
|
+
const hasReasons = read.reasons.length > 0;
|
|
130
|
+
// A flow that asks for a reason has nothing to ask with when the list is empty.
|
|
131
|
+
const required = hasReasons && read.flow === 'reason_required';
|
|
132
|
+
const groups = groupReasons(read.reasons);
|
|
133
|
+
const named = groups.filter((g) => g.name !== '');
|
|
134
|
+
const bare = groups.filter((g) => g.name === '').flatMap((g) => g.items);
|
|
135
|
+
// The reason is read from the CURRENT list: if `reporting` changes while the dialog is open and the id the player chose is gone, the
|
|
136
|
+
// select shows the placeholder, and this is undefined, so nothing stale is sent (and a required reason is asked for again).
|
|
137
|
+
const chosen = read.reasons.find((r) => r.id === reason);
|
|
138
|
+
const reasonValue = chosen === undefined ? '' : chosen.id;
|
|
139
|
+
const hint = chosen !== undefined && chosen.hint !== '' ? chosen.hint : null;
|
|
140
|
+
const noteShown = hasReasons && noteOpen;
|
|
141
|
+
|
|
142
|
+
// 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.
|
|
143
|
+
useEffect(() => {
|
|
144
|
+
if (result !== null) heading.current?.focus();
|
|
145
|
+
}, [result]);
|
|
146
|
+
// "Add a note" is replaced by the field: focus follows it.
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (focusNote) {
|
|
149
|
+
noteField.current?.focus();
|
|
150
|
+
setFocusNote(false);
|
|
151
|
+
}
|
|
152
|
+
}, [focusNote]);
|
|
153
|
+
|
|
154
|
+
const send = async (draft: ReportDraft): Promise<void> => {
|
|
155
|
+
sending.current = true;
|
|
156
|
+
setBusy(true);
|
|
157
|
+
setFailure(null);
|
|
158
|
+
try {
|
|
159
|
+
setResult(resultOf<ReportResult>(await onSubmit(draft), { ok: false, refused: 'unknown' } as unknown as ReportResult));
|
|
160
|
+
} catch (error) {
|
|
161
|
+
setFailure(failureOf(error));
|
|
162
|
+
} finally {
|
|
163
|
+
sending.current = false;
|
|
164
|
+
setBusy(false);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const trimmed = note.trim();
|
|
169
|
+
const submit = (e: FormEvent): void => {
|
|
170
|
+
e.preventDefault();
|
|
171
|
+
if (sending.current) return;
|
|
172
|
+
if (required && reasonValue === '') {
|
|
173
|
+
setMissing(true);
|
|
174
|
+
select.current?.focus();
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const draft: ReportDraft = {};
|
|
178
|
+
if (reasonValue !== '') draft.reason = reasonValue;
|
|
179
|
+
if (noteShown && trimmed !== '') draft.note = trimmed;
|
|
180
|
+
void send(draft);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const quote = quoted(line);
|
|
184
|
+
const title = quote !== '' ? t('player.report.titleOneTap') : targetName === undefined ? t('player.report.titleOneTapPlayer') : t('player.report.titleOneTapNamed', { name: targetName });
|
|
185
|
+
const taken = result?.ok === true;
|
|
186
|
+
const refusal = result !== null && !taken ? result : null;
|
|
187
|
+
// After an answer the heading says what happened (a question would be stale), and focus is on it.
|
|
188
|
+
const shownTitle = result === null ? title : taken ? t('player.report.titleSent') : t('player.report.titleRefused');
|
|
189
|
+
const remaining = taken && typeof result.remaining === 'number' && Number.isFinite(result.remaining) && result.remaining >= 0 ? result.remaining : null;
|
|
190
|
+
const refusedKey = (r: unknown): PlayerText => {
|
|
191
|
+
const refused = (r as { refused?: unknown }).refused;
|
|
192
|
+
return typeof refused === 'string' && Object.hasOwn(REFUSED_TEXT, refused) ? REFUSED_TEXT[refused as ReportRefusal] : 'player.report.refused.unknown';
|
|
193
|
+
};
|
|
194
|
+
const option = (r: { id: string; label: string }) => (
|
|
195
|
+
<option key={r.id} value={r.id}>
|
|
196
|
+
{r.label}
|
|
197
|
+
</option>
|
|
198
|
+
);
|
|
199
|
+
const describedBy = [hint === null ? '' : reasonHintId, missing ? missingId : ''].filter((id) => id !== '').join(' ');
|
|
200
|
+
|
|
201
|
+
return (
|
|
202
|
+
<Portal container={container}>
|
|
203
|
+
<Root theme={theme} className={className}>
|
|
204
|
+
<Dialog labelledBy={titleId} initialFocus={heading} onEscape={() => !sending.current && onClose()} {...(returnFocusTo === undefined ? {} : { returnFocusTo })}>
|
|
205
|
+
<h2 id={titleId} ref={heading} className="cw-title" tabIndex={-1}>
|
|
206
|
+
{shownTitle}
|
|
207
|
+
</h2>
|
|
208
|
+
{/* Always there, so what is written into it is announced: the thank-you. */}
|
|
209
|
+
<div role="status" className="cw-live">
|
|
210
|
+
{taken && (
|
|
211
|
+
<p className="cw-result">
|
|
212
|
+
{thanks === '' ? t('player.report.thanks') : thanks}
|
|
213
|
+
{remaining !== null && ` ${t('player.report.remaining', { count: remaining })}`}
|
|
214
|
+
</p>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
{result === null ? (
|
|
218
|
+
<form onSubmit={submit} noValidate>
|
|
219
|
+
<p className="cw-body">{required ? t('player.report.intro') : t('player.report.oneTap.intro')}</p>
|
|
220
|
+
{quote !== '' && <blockquote className="cw-quote">{t('player.report.quote', { line: quote })}</blockquote>}
|
|
221
|
+
{hasReasons && (
|
|
222
|
+
<div className="cw-field">
|
|
223
|
+
<label className="cw-label" htmlFor={reasonId}>
|
|
224
|
+
{required ? t('player.report.whyRequired') : t('player.report.why')}
|
|
225
|
+
</label>
|
|
226
|
+
<select
|
|
227
|
+
id={reasonId}
|
|
228
|
+
ref={select}
|
|
229
|
+
className="cw-select"
|
|
230
|
+
value={reasonValue}
|
|
231
|
+
required={required}
|
|
232
|
+
aria-invalid={missing || undefined}
|
|
233
|
+
aria-describedby={describedBy === '' ? undefined : describedBy}
|
|
234
|
+
onChange={(e) => {
|
|
235
|
+
const id = e.target.value;
|
|
236
|
+
setReason(id);
|
|
237
|
+
setMissing(false);
|
|
238
|
+
if (read.reasons.find((r) => r.id === id)?.asksNote === true) setNoteOpen(true);
|
|
239
|
+
}}
|
|
240
|
+
>
|
|
241
|
+
<option value="">{required ? t('player.report.chooseRequired') : t('player.report.choose')}</option>
|
|
242
|
+
{named.map((g) => (
|
|
243
|
+
<optgroup key={g.name} label={g.name}>
|
|
244
|
+
{g.items.map(option)}
|
|
245
|
+
</optgroup>
|
|
246
|
+
))}
|
|
247
|
+
{bare.map(option)}
|
|
248
|
+
</select>
|
|
249
|
+
{hint !== null && (
|
|
250
|
+
<p id={reasonHintId} className="cw-hint">
|
|
251
|
+
{hint}
|
|
252
|
+
</p>
|
|
253
|
+
)}
|
|
254
|
+
{missing && (
|
|
255
|
+
<p id={missingId} className="cw-field-error" role="alert">
|
|
256
|
+
{t('player.report.needReason')}
|
|
257
|
+
</p>
|
|
258
|
+
)}
|
|
259
|
+
</div>
|
|
260
|
+
)}
|
|
261
|
+
{noteShown && (
|
|
262
|
+
<div className="cw-field">
|
|
263
|
+
<label className="cw-label" htmlFor={noteId}>
|
|
264
|
+
{t('player.report.note')}
|
|
265
|
+
</label>
|
|
266
|
+
<textarea id={noteId} ref={noteField} className="cw-textarea" rows={3} maxLength={NOTE_MAX} value={note} aria-describedby={noteHintId} onChange={(e) => setNote(e.target.value)} />
|
|
267
|
+
<p id={noteHintId} className="cw-hint">
|
|
268
|
+
{t('player.report.noteHint', { max: NOTE_MAX })}
|
|
269
|
+
</p>
|
|
270
|
+
</div>
|
|
271
|
+
)}
|
|
272
|
+
{hasReasons && !noteShown && (
|
|
273
|
+
// No aria-expanded: this is a one-way door. It is removed when pressed (the note takes its place) and focus moves to the note.
|
|
274
|
+
<button
|
|
275
|
+
type="button"
|
|
276
|
+
className="cw-linkbtn"
|
|
277
|
+
onClick={() => {
|
|
278
|
+
setNoteOpen(true);
|
|
279
|
+
setFocusNote(true);
|
|
280
|
+
}}
|
|
281
|
+
>
|
|
282
|
+
{t('player.report.addNote')}
|
|
283
|
+
</button>
|
|
284
|
+
)}
|
|
285
|
+
{extra !== undefined && extra !== null && extra !== false && <div className="cw-extra">{extra}</div>}
|
|
286
|
+
{failure !== null && (
|
|
287
|
+
<p className="cw-error" role="alert">
|
|
288
|
+
{t('player.report.failed')} {errorText(t, failure)}
|
|
289
|
+
</p>
|
|
290
|
+
)}
|
|
291
|
+
<div className="cw-actions">
|
|
292
|
+
<button type="submit" className="cw-btn cw-btn--primary" aria-disabled={busy || undefined} aria-busy={busy || undefined}>
|
|
293
|
+
{t('player.report.oneTap.send')}
|
|
294
|
+
</button>
|
|
295
|
+
{/* Closing while a report is on its way would lose the answer: busy, like Escape. */}
|
|
296
|
+
<button type="button" className="cw-btn" aria-disabled={busy || undefined} onClick={() => !sending.current && onClose()}>
|
|
297
|
+
{t('player.report.cancel')}
|
|
298
|
+
</button>
|
|
299
|
+
</div>
|
|
300
|
+
</form>
|
|
301
|
+
) : (
|
|
302
|
+
<>
|
|
303
|
+
{!taken && (
|
|
304
|
+
<p className="cw-error" role="alert">
|
|
305
|
+
{t(refusedKey(refusal))}
|
|
306
|
+
</p>
|
|
307
|
+
)}
|
|
308
|
+
<div className="cw-actions">
|
|
309
|
+
<button type="button" className="cw-btn cw-btn--primary" aria-disabled={busy || undefined} onClick={() => !sending.current && onClose()}>
|
|
310
|
+
{t('player.report.close')}
|
|
311
|
+
</button>
|
|
312
|
+
</div>
|
|
313
|
+
</>
|
|
314
|
+
)}
|
|
315
|
+
</Dialog>
|
|
316
|
+
</Root>
|
|
317
|
+
</Portal>
|
|
318
|
+
);
|
|
319
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { useId } from 'react';
|
|
2
|
+
import type { PlayerClient } from './client.js';
|
|
3
|
+
import { usePlayerData } from './hooks.js';
|
|
4
|
+
import { ErrorBlock, Root, useUi } from './ui.js';
|
|
5
|
+
import type { PlayerUiProps } from './ui.js';
|
|
6
|
+
|
|
7
|
+
export interface RulesPageProps extends PlayerUiProps {
|
|
8
|
+
client: PlayerClient;
|
|
9
|
+
/** Change it to load the rules again. */
|
|
10
|
+
refreshKey?: number | string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** The community rules: the profile's summary and the numbered rules from `GET /rules`. Text only, never HTML. */
|
|
14
|
+
export function RulesPage(props: RulesPageProps) {
|
|
15
|
+
const { client, refreshKey, theme, className } = props;
|
|
16
|
+
const ui = useUi(props);
|
|
17
|
+
const { t } = ui;
|
|
18
|
+
const state = usePlayerData(() => client.rules(), refreshKey);
|
|
19
|
+
const titleId = useId();
|
|
20
|
+
const data = state.data;
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Root theme={theme} className={className}>
|
|
24
|
+
<section className="cw-section" aria-labelledby={titleId}>
|
|
25
|
+
<h2 id={titleId} className="cw-title">
|
|
26
|
+
{t('player.rules.title')}
|
|
27
|
+
</h2>
|
|
28
|
+
{state.error !== null && <ErrorBlock ui={ui} what={t('player.rules.loadFailed')} error={state.error} onRetry={state.reload} />}
|
|
29
|
+
{data === undefined && state.error === null && (
|
|
30
|
+
<p className="cw-state" role="status">
|
|
31
|
+
{t('player.rules.loading')}
|
|
32
|
+
</p>
|
|
33
|
+
)}
|
|
34
|
+
{data !== undefined && data.summary !== '' && <p className="cw-summary">{data.summary}</p>}
|
|
35
|
+
{data !== undefined && data.rules.length === 0 && <p className="cw-state">{t('player.rules.empty')}</p>}
|
|
36
|
+
{data !== undefined && data.rules.length > 0 && (
|
|
37
|
+
// The kit's own numbers, so a retired rule leaves a gap instead of renumbering the ones after it.
|
|
38
|
+
<ol className="cw-rules" aria-labelledby={titleId}>
|
|
39
|
+
{data.rules.map((rule) => (
|
|
40
|
+
<li key={rule.number} value={rule.number}>
|
|
41
|
+
<strong className="cw-rule-title">{rule.title}</strong> <span className="cw-rule-text">{rule.text}</span>
|
|
42
|
+
</li>
|
|
43
|
+
))}
|
|
44
|
+
</ol>
|
|
45
|
+
)}
|
|
46
|
+
</section>
|
|
47
|
+
</Root>
|
|
48
|
+
);
|
|
49
|
+
}
|
package/src/client.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import type { PlayerNotice, PlayerReporting } from '@chatwarden/core';
|
|
2
|
+
import { readReporting } from './reporting.js';
|
|
3
|
+
import type { Transport, TransportRequest, TransportResponse } from './transport.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Why a call to the player routes failed. `status` is the HTTP status, or 0 when no answer arrived. `code` is the kit's error
|
|
7
|
+
* code (`not_found`, `rate_limited`, ...), `network` for a dropped connection, `bad_response` for an answer that is not the
|
|
8
|
+
* shape the components expect, `unexpected` for anything else, and `http_<status>` when the body is not a kit error.
|
|
9
|
+
* `retryAfter` is the seconds from a `retry-after` header (a 429).
|
|
10
|
+
*/
|
|
11
|
+
export class PlayerError extends Error {
|
|
12
|
+
readonly status: number;
|
|
13
|
+
readonly code: string;
|
|
14
|
+
readonly retryAfter: number | undefined;
|
|
15
|
+
|
|
16
|
+
constructor(init: { status: number; code: string; message: string; retryAfter?: number }) {
|
|
17
|
+
super(init.message);
|
|
18
|
+
this.name = 'PlayerError';
|
|
19
|
+
this.status = init.status;
|
|
20
|
+
this.code = init.code;
|
|
21
|
+
this.retryAfter = init.retryAfter;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A `PlayerError` as is; anything else (a bug in the caller's code, say) becomes status 0, code `unexpected`. */
|
|
26
|
+
export function toPlayerError(e: unknown): PlayerError {
|
|
27
|
+
if (e instanceof PlayerError) return e;
|
|
28
|
+
return new PlayerError({ status: 0, code: 'unexpected', message: e instanceof Error ? e.message : 'Something went wrong.' });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
32
|
+
const text = (v: unknown): v is string => typeof v === 'string';
|
|
33
|
+
|
|
34
|
+
function errorOf(res: TransportResponse): PlayerError {
|
|
35
|
+
const b = res.body;
|
|
36
|
+
const raw = res.headers['retry-after'];
|
|
37
|
+
const rest = raw !== undefined && /^\d{1,9}$/.test(raw) ? { retryAfter: Number(raw) } : {};
|
|
38
|
+
if (isObject(b) && text(b.error) && text(b.message)) return new PlayerError({ status: res.status, code: b.error, message: b.message, ...rest });
|
|
39
|
+
// A proxy's HTML page, or an empty body: say the status, never echo the body.
|
|
40
|
+
return new PlayerError({ status: res.status, code: `http_${res.status}`, message: `The server answered with status ${res.status}.`, ...rest });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function send(transport: Transport, req: TransportRequest): Promise<unknown> {
|
|
44
|
+
let res: TransportResponse;
|
|
45
|
+
try {
|
|
46
|
+
res = await transport(req);
|
|
47
|
+
} catch (e) {
|
|
48
|
+
throw new PlayerError({ status: 0, code: 'network', message: e instanceof Error && e.name === 'AbortError' ? 'The request was cancelled.' : 'Could not reach the server.' });
|
|
49
|
+
}
|
|
50
|
+
if (res.status < 200 || res.status > 299) throw errorOf(res);
|
|
51
|
+
return res.body;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const bad = (): PlayerError => new PlayerError({ status: 200, code: 'bad_response', message: 'The server sent an answer these components do not understand.' });
|
|
55
|
+
|
|
56
|
+
/** One notice, keeping only the fields the kit documents for a player. Anything else the server sent never reaches the page. */
|
|
57
|
+
function noticeOf(raw: unknown): PlayerNotice | null {
|
|
58
|
+
if (!isObject(raw) || !text(raw.id) || !text(raw.kind)) return null;
|
|
59
|
+
const excerpt = Array.isArray(raw.excerpt)
|
|
60
|
+
? raw.excerpt.filter(isObject).filter((l) => text(l.text) && text(l.day)).map((l) => ({ text: l.text as string, day: l.day as string }))
|
|
61
|
+
: [];
|
|
62
|
+
return {
|
|
63
|
+
id: raw.id,
|
|
64
|
+
kind: raw.kind as PlayerNotice['kind'],
|
|
65
|
+
rule: typeof raw.rule === 'number' || raw.rule === 'uncovered' ? raw.rule : null,
|
|
66
|
+
ruleLabel: text(raw.ruleLabel) ? raw.ruleLabel : null,
|
|
67
|
+
until: typeof raw.until === 'number' && Number.isFinite(raw.until) ? raw.until : null,
|
|
68
|
+
refCode: text(raw.refCode) ? raw.refCode : null,
|
|
69
|
+
excerpt,
|
|
70
|
+
createdAt: typeof raw.createdAt === 'number' && Number.isFinite(raw.createdAt) ? raw.createdAt : 0,
|
|
71
|
+
mustAcknowledge: raw.mustAcknowledge === true,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function noticesOf(body: unknown, key: 'notices' | 'record'): PlayerNotice[] {
|
|
76
|
+
if (!isObject(body) || !Array.isArray(body[key])) throw bad();
|
|
77
|
+
return (body[key] as unknown[]).map(noticeOf).filter((n): n is PlayerNotice => n !== null);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One rule as `GET /rules` gives it. */
|
|
81
|
+
export interface PlayerRule {
|
|
82
|
+
number: number;
|
|
83
|
+
title: string;
|
|
84
|
+
text: string;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `GET /rules`: the profile's one-line summary, the active rules and how reporting works here (`undefined` from an older server). */
|
|
88
|
+
export interface PlayerRules {
|
|
89
|
+
summary: string;
|
|
90
|
+
rules: PlayerRule[];
|
|
91
|
+
/** The flow, the reasons a player may give and the thank-you: pass it to `ReportDialog` as `reporting`. */
|
|
92
|
+
reporting?: PlayerReporting;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface PlayerClient {
|
|
96
|
+
/** `GET /notices`: notices not yet acknowledged, as the kit sends them (oldest first). */
|
|
97
|
+
notices(): Promise<PlayerNotice[]>;
|
|
98
|
+
/** `POST /notices/:id/ack`: `true` when it was this player's live notice, `false` (still a 200) when it was not, or it is gone. */
|
|
99
|
+
ack(id: string): Promise<boolean>;
|
|
100
|
+
/** `GET /record`: the player's sanctions that still apply (seen or not). */
|
|
101
|
+
record(): Promise<PlayerNotice[]>;
|
|
102
|
+
/** `GET /rules`: the summary, the numbered rules and the reporting settings. */
|
|
103
|
+
rules(): Promise<PlayerRules>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface PlayerClientOptions {
|
|
107
|
+
/** How to reach the player routes: `fetchTransport({ baseUrl: '/player' })`, or any function with the same shape. */
|
|
108
|
+
transport: Transport;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A client over the kit's player routes. It resolves with typed values and rejects with a `PlayerError`. */
|
|
112
|
+
export function createPlayerClient({ transport }: PlayerClientOptions): PlayerClient {
|
|
113
|
+
return {
|
|
114
|
+
notices: async () => noticesOf(await send(transport, { method: 'GET', path: '/notices' }), 'notices'),
|
|
115
|
+
record: async () => noticesOf(await send(transport, { method: 'GET', path: '/record' }), 'record'),
|
|
116
|
+
ack: async (id) => {
|
|
117
|
+
const body = await send(transport, { method: 'POST', path: `/notices/${encodeURIComponent(id)}/ack` });
|
|
118
|
+
if (!isObject(body) || typeof body.ok !== 'boolean') throw bad();
|
|
119
|
+
return body.ok;
|
|
120
|
+
},
|
|
121
|
+
rules: async () => {
|
|
122
|
+
const body = await send(transport, { method: 'GET', path: '/rules' });
|
|
123
|
+
if (!isObject(body) || !text(body.summary) || !Array.isArray(body.rules)) throw bad();
|
|
124
|
+
const rules = body.rules
|
|
125
|
+
.filter(isObject)
|
|
126
|
+
.filter((r) => typeof r.number === 'number' && text(r.title) && text(r.text))
|
|
127
|
+
.map((r) => ({ number: r.number as number, title: r.title as string, text: r.text as string }));
|
|
128
|
+
const reporting = readReporting(body.reporting);
|
|
129
|
+
return { summary: body.summary, rules, ...(reporting === undefined ? {} : { reporting }) };
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** The longest note the kit keeps (a test keeps this the same as core's `REPORT_NOTE_MAX`). */
|
|
2
|
+
export const NOTE_MAX = 500;
|
|
3
|
+
|
|
4
|
+
/** How many days a warning stays on a record (a test keeps this the same as core's notice record window). */
|
|
5
|
+
export const WARNING_DAYS = 90;
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Small pure helpers for showing days, times and waits. Times use Intl with the component's `locale`; none holds English.
|
|
2
|
+
|
|
3
|
+
const MIN = 60_000;
|
|
4
|
+
const HOUR = 60 * MIN;
|
|
5
|
+
const DAY = 24 * HOUR;
|
|
6
|
+
|
|
7
|
+
function safeLocale(locale: string): string {
|
|
8
|
+
try {
|
|
9
|
+
return Intl.getCanonicalLocales(locale)[0] ?? 'en';
|
|
10
|
+
} catch {
|
|
11
|
+
return 'en';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Some runtimes put a narrow no-break space before "PM". A plain space reads the same and survives a copy. */
|
|
16
|
+
const plain = (text: string): string => text.replace(/[\u202f\u00a0]/g, ' ');
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A UTC day from the kit (`2027-01-15`) as the locale writes a date (`Jan 15, 2027`), in UTC whatever the viewer's zone: the kit
|
|
20
|
+
* names the day in UTC, so a notice never shows a different day than the moderator's tools. A value that is not a day comes back
|
|
21
|
+
* as it was; it is never shown as "Invalid Date".
|
|
22
|
+
*/
|
|
23
|
+
export function dayText(day: string, locale: string): string {
|
|
24
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day);
|
|
25
|
+
if (m === null) return day;
|
|
26
|
+
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
|
|
27
|
+
const date = new Date(Date.UTC(y, mo - 1, d));
|
|
28
|
+
if (date.getUTCFullYear() !== y || date.getUTCMonth() !== mo - 1 || date.getUTCDate() !== d) return day;
|
|
29
|
+
return new Intl.DateTimeFormat(safeLocale(locale), { dateStyle: 'medium', timeZone: 'UTC' }).format(date);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A date and time: `Jan 15, 2027, 8:30 PM`. Pass a time zone for a fixed one; the default is the viewer's. An unknown zone is ignored. */
|
|
33
|
+
export function untilText(at: number, locale: string, timeZone?: string): string {
|
|
34
|
+
const options: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'short' };
|
|
35
|
+
try {
|
|
36
|
+
return plain(new Intl.DateTimeFormat(safeLocale(locale), { ...options, ...(timeZone === undefined ? {} : { timeZone }) }).format(at));
|
|
37
|
+
} catch {
|
|
38
|
+
return plain(new Intl.DateTimeFormat(safeLocale(locale), options).format(at));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* How long is left until `at`, as the locale says it ("in 3 hours"): minutes under an hour (a part of a minute counts as a whole
|
|
44
|
+
* one, so a live restriction never reads "in 0 minutes"), hours under two days, then days. Null when `at` is now or has passed.
|
|
45
|
+
*/
|
|
46
|
+
export function timeLeft(at: number, now: number, locale: string): string | null {
|
|
47
|
+
const left = at - now;
|
|
48
|
+
if (!(left > 0)) return null;
|
|
49
|
+
const rtf = new Intl.RelativeTimeFormat(safeLocale(locale), { numeric: 'always' });
|
|
50
|
+
if (left < HOUR) return rtf.format(Math.max(1, Math.ceil(left / MIN)), 'minute');
|
|
51
|
+
if (left < 2 * DAY) return rtf.format(Math.round(left / HOUR), 'hour');
|
|
52
|
+
return rtf.format(Math.round(left / DAY), 'day');
|
|
53
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { PlayerError, toPlayerError } from './client.js';
|
|
3
|
+
|
|
4
|
+
export interface PlayerData<T> {
|
|
5
|
+
/** The last answer, kept when a later load fails. */
|
|
6
|
+
data: T | undefined;
|
|
7
|
+
/** The last failure, or null. Cleared when a load starts. */
|
|
8
|
+
error: PlayerError | null;
|
|
9
|
+
/** Starts the same load again. */
|
|
10
|
+
reload: () => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Runs `load` on mount, again when `refreshKey` changes, and again on `reload()`. An answer from a load that has been replaced
|
|
15
|
+
* (or from a component that is gone) is dropped, so a slow old request never overwrites a newer one.
|
|
16
|
+
*/
|
|
17
|
+
export function usePlayerData<T>(load: () => Promise<T>, refreshKey: unknown): PlayerData<T> {
|
|
18
|
+
const [state, setState] = useState<{ data: T | undefined; error: PlayerError | null }>({ data: undefined, error: null });
|
|
19
|
+
const [again, setAgain] = useState(0);
|
|
20
|
+
const loader = useRef(load);
|
|
21
|
+
loader.current = load;
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
let live = true;
|
|
25
|
+
setState((s) => (s.error === null ? s : { ...s, error: null }));
|
|
26
|
+
loader.current().then(
|
|
27
|
+
(data) => {
|
|
28
|
+
if (live) setState({ data, error: null });
|
|
29
|
+
},
|
|
30
|
+
(e: unknown) => {
|
|
31
|
+
if (live) setState((s) => ({ data: s.data, error: toPlayerError(e) }));
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
return () => {
|
|
35
|
+
live = false;
|
|
36
|
+
};
|
|
37
|
+
}, [refreshKey, again]);
|
|
38
|
+
|
|
39
|
+
const reload = useCallback(() => setAgain((n) => n + 1), []);
|
|
40
|
+
return { data: state.data, error: state.error, reload };
|
|
41
|
+
}
|