@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
|
@@ -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
|
+
}
|
package/src/i18n.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// Every word the player components show is a key in this catalog; components never hold English.
|
|
2
|
+
//
|
|
3
|
+
// Key naming: `player.<area>.<thing>[.<detail>]`, lower camel case segments, for example `player.notice.title.warning`.
|
|
4
|
+
// Areas: error, state, notice, notices, record, rules, restricted, report.
|
|
5
|
+
// Plural forms add the CLDR category as the last segment: `player.report.remaining.one`, `.other`. A caller asks for the base
|
|
6
|
+
// key with a numeric `count` (`t('player.report.remaining', { count: 3 })`); `.zero` is used for 0 when it exists.
|
|
7
|
+
// `{name}` marks a value to fill in. To translate, spread this catalog over yours so a missing key falls back to English:
|
|
8
|
+
// `createT({ ...en, ...de }, 'de')`.
|
|
9
|
+
//
|
|
10
|
+
// The copy is written for a player in the middle of a game: second person, sentence case, plain words, and never shaming. It says
|
|
11
|
+
// what happened, until when, and what the player can do.
|
|
12
|
+
|
|
13
|
+
export const en = {
|
|
14
|
+
'player.error.network': 'Could not reach the server. Check your connection and try again.',
|
|
15
|
+
'player.error.unauthorized': 'You are signed out. Sign in again, then try again.',
|
|
16
|
+
'player.error.forbidden': 'This account cannot do that.',
|
|
17
|
+
'player.error.notFound': 'That could not be found.',
|
|
18
|
+
'player.error.rateLimited': 'Too many requests. Try again in a moment.',
|
|
19
|
+
'player.error.rateLimited.after.one': 'Too many requests. Try again in {count} second.',
|
|
20
|
+
'player.error.rateLimited.after.other': 'Too many requests. Try again in {count} seconds.',
|
|
21
|
+
'player.error.server': 'The server had a problem. Try again in a moment.',
|
|
22
|
+
'player.error.generic': 'Something went wrong.',
|
|
23
|
+
|
|
24
|
+
'player.state.retry': 'Try again',
|
|
25
|
+
|
|
26
|
+
'player.notices.loadFailed': 'We could not check for notices.',
|
|
27
|
+
|
|
28
|
+
'player.notice.title.warning': 'You have a warning',
|
|
29
|
+
'player.notice.title.chat': 'Your chat is turned off',
|
|
30
|
+
'player.notice.title.area': 'You cannot join public rooms',
|
|
31
|
+
'player.notice.title.ban': 'You are banned',
|
|
32
|
+
'player.notice.title.report_outcome': 'Thanks for your report',
|
|
33
|
+
'player.notice.title.fallback': 'A message from the moderators',
|
|
34
|
+
'player.notice.body.warning': 'A moderator gave you a warning for something you wrote. You can keep playing.',
|
|
35
|
+
'player.notice.body.chat': 'A moderator turned off your chat. You can still play.',
|
|
36
|
+
'player.notice.body.area': 'A moderator stopped you joining public rooms and matchmaking. You can still play in private rooms.',
|
|
37
|
+
'player.notice.body.ban': 'A moderator banned you from playing.',
|
|
38
|
+
'player.notice.body.report_outcome': 'A moderator looked at your report and took action. We do not share what was done.',
|
|
39
|
+
'player.notice.body.fallback': 'There is a new message about your account.',
|
|
40
|
+
'player.notice.position': 'Notice {n} of {total}',
|
|
41
|
+
'player.notice.rule': 'Rule',
|
|
42
|
+
'player.notice.given': 'Given',
|
|
43
|
+
'player.notice.until': 'Until',
|
|
44
|
+
'player.notice.ended': 'Ended',
|
|
45
|
+
'player.notice.noEnd': 'No end date',
|
|
46
|
+
'player.notice.onRecordUntil': 'On your record until',
|
|
47
|
+
'player.notice.untilLeft': '{when} ({left})',
|
|
48
|
+
'player.notice.ref': 'Reference',
|
|
49
|
+
'player.notice.refHint': 'Quote this code if you contact us.',
|
|
50
|
+
'player.notice.lines': 'Your messages that led to this',
|
|
51
|
+
'player.notice.todo.warning': 'Press Acknowledge to confirm you saw this.',
|
|
52
|
+
'player.notice.acknowledge': 'Acknowledge',
|
|
53
|
+
'player.notice.gotIt': 'Got it',
|
|
54
|
+
'player.notice.appeal': 'Think this is a mistake?',
|
|
55
|
+
'player.notice.ackFailed': 'Your notice is still here.',
|
|
56
|
+
'player.notice.closeForNow': 'Close for now',
|
|
57
|
+
|
|
58
|
+
'player.record.title': 'Your record',
|
|
59
|
+
'player.record.loading': 'Loading your record',
|
|
60
|
+
'player.record.loadFailed': 'We could not load your record.',
|
|
61
|
+
'player.record.intro': 'This is what is on your record now. A warning stays for {days} days.',
|
|
62
|
+
'player.record.empty': 'Your record is clear. Nothing is on it right now.',
|
|
63
|
+
'player.record.kind.warning': 'Warning',
|
|
64
|
+
'player.record.kind.chat': 'Chat restriction',
|
|
65
|
+
'player.record.kind.area': 'Public rooms restriction',
|
|
66
|
+
'player.record.kind.ban': 'Ban',
|
|
67
|
+
'player.record.kind.fallback': 'Notice',
|
|
68
|
+
|
|
69
|
+
'player.rules.title': 'Community rules',
|
|
70
|
+
'player.rules.loading': 'Loading the rules',
|
|
71
|
+
'player.rules.loadFailed': 'We could not load the rules.',
|
|
72
|
+
'player.rules.empty': 'No rules are published yet.',
|
|
73
|
+
|
|
74
|
+
'player.restricted.moderator.until': 'A moderator turned off your chat until {when} ({left}).',
|
|
75
|
+
'player.restricted.moderator.noEnd': 'A moderator turned off your chat. It does not end on its own.',
|
|
76
|
+
'player.restricted.filter.until': 'Your chat is off until {when} ({left}). The automatic filter caught a message.',
|
|
77
|
+
'player.restricted.filter.noEnd': 'Your chat is off. It does not end on its own. The automatic filter caught a message.',
|
|
78
|
+
'player.restricted.ended': 'Your chat is back on. The restriction ended {when}.',
|
|
79
|
+
'player.restricted.ref': 'Reference {code}',
|
|
80
|
+
|
|
81
|
+
'player.report.title': 'Report a player',
|
|
82
|
+
'player.report.titleNamed': 'Report {name}',
|
|
83
|
+
'player.report.intro': 'Tell us what happened and a moderator will look at it.',
|
|
84
|
+
'player.report.legend': 'What happened?',
|
|
85
|
+
'player.report.titleOneTap': 'Report this message?',
|
|
86
|
+
'player.report.titleOneTapNamed': 'Report {name}?',
|
|
87
|
+
'player.report.titleOneTapPlayer': 'Report this player?',
|
|
88
|
+
'player.report.titleSent': 'Report sent',
|
|
89
|
+
'player.report.titleRefused': "Couldn't send the report",
|
|
90
|
+
'player.report.oneTap.intro': 'We will pass it to the moderators. You do not need to say why.',
|
|
91
|
+
'player.report.oneTap.send': 'Report',
|
|
92
|
+
'player.report.quote': '“{line}”',
|
|
93
|
+
'player.report.skip': 'Skip and report',
|
|
94
|
+
'player.report.needReason': 'Choose a reason.',
|
|
95
|
+
'player.report.needReasonOrSkip': 'Choose what happened, or skip.',
|
|
96
|
+
'player.report.addReason': 'Add a reason',
|
|
97
|
+
'player.report.addReasonHint': 'This helps, but the moderators decide what it really is. You can add this for a few minutes after you report.',
|
|
98
|
+
'player.report.detail.legend': 'What was it about?',
|
|
99
|
+
'player.report.detail.send': 'Add to my report',
|
|
100
|
+
'player.report.detail.need': 'Choose a reason or write a note.',
|
|
101
|
+
'player.report.detail.added': 'Added. Thanks.',
|
|
102
|
+
'player.report.detail.failed': 'We could not add that.',
|
|
103
|
+
'player.report.detail.refused.off': 'Adding to a report is not available right now.',
|
|
104
|
+
'player.report.detail.refused.not_found': 'We cannot find that report any more.',
|
|
105
|
+
'player.report.detail.refused.not_reporter': 'Only the player who sent a report can add to it.',
|
|
106
|
+
'player.report.detail.refused.too_late': 'It is too late to add to this report. We still have the report.',
|
|
107
|
+
'player.report.detail.refused.already_added': 'You already added to this report.',
|
|
108
|
+
'player.report.detail.refused.reason_unknown': 'That reason is not available any more. Your report is still with us.',
|
|
109
|
+
'player.report.note': 'Add a note (optional)',
|
|
110
|
+
'player.report.noteHint': 'A moderator will read this. Up to {max} characters. Do not include passwords or personal details.',
|
|
111
|
+
'player.report.send': 'Send report',
|
|
112
|
+
'player.report.cancel': 'Cancel',
|
|
113
|
+
'player.report.close': 'Close',
|
|
114
|
+
'player.report.failed': 'Your report was not sent.',
|
|
115
|
+
'player.report.thanks': 'Thanks. We got your report.',
|
|
116
|
+
'player.report.remaining.zero': 'You have no more reports left today.',
|
|
117
|
+
'player.report.remaining.one': 'You can send {count} more report today.',
|
|
118
|
+
'player.report.remaining.other': 'You can send {count} more reports today.',
|
|
119
|
+
'player.report.refused.duplicate': 'You already reported this player here. We have your report.',
|
|
120
|
+
'player.report.refused.rate': 'You have sent as many reports as you can today. You can send more tomorrow.',
|
|
121
|
+
'player.report.refused.self': 'You cannot report yourself.',
|
|
122
|
+
'player.report.refused.not_player': 'We cannot find that player any more. They may have left.',
|
|
123
|
+
'player.report.refused.off': 'Reports are not available right now. Try again later.',
|
|
124
|
+
'player.report.refused.suspended': 'You cannot send reports from this account for now.',
|
|
125
|
+
'player.report.refused.reason_required': 'This report needs a reason. Close this and report again to choose one.',
|
|
126
|
+
'player.report.refused.reason_unknown': 'That reason is not available any more. Close this and report again.',
|
|
127
|
+
'player.report.refused.unknown': 'We could not take that report.',
|
|
128
|
+
} as const;
|
|
129
|
+
|
|
130
|
+
/** A catalog: keys to English (or translated) text. Any object of strings will do. */
|
|
131
|
+
export type PlayerCatalog = Readonly<Record<string, string>>;
|
|
132
|
+
|
|
133
|
+
type PluralForm = 'zero' | 'one' | 'two' | 'few' | 'many' | 'other';
|
|
134
|
+
type BaseKey<K extends string> = K extends `${infer B}.${PluralForm}` ? B : K;
|
|
135
|
+
|
|
136
|
+
/** Every key a component may ask for: the English catalog's keys with the plural suffix taken off. */
|
|
137
|
+
export type PlayerText = BaseKey<keyof typeof en>;
|
|
138
|
+
|
|
139
|
+
export type PlayerParams = Readonly<Record<string, string | number>>;
|
|
140
|
+
|
|
141
|
+
/** The `t` the components take: a key and optional values, always a string back. */
|
|
142
|
+
export type PlayerT = (key: PlayerText, params?: PlayerParams) => string;
|
|
143
|
+
|
|
144
|
+
const isCount = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v);
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Builds the `t` function for a catalog and a locale.
|
|
148
|
+
* - `{name}` is replaced by `params.name`; a name with no value stays as written; a value is never expanded again.
|
|
149
|
+
* - Numbers are formatted for the locale (`1,234`).
|
|
150
|
+
* - With a numeric `count`, the plural form for the locale is used (`key.one`, `key.other`, ...), `key.zero` for 0 if it
|
|
151
|
+
* exists, and `key.other` when the locale's form is missing.
|
|
152
|
+
* - A key with no text comes back as the key itself, so a gap in a translation is visible instead of blank.
|
|
153
|
+
*/
|
|
154
|
+
export function createT(catalog: PlayerCatalog, locale = 'en'): PlayerT {
|
|
155
|
+
let numbers: Intl.NumberFormat;
|
|
156
|
+
let plurals: Intl.PluralRules;
|
|
157
|
+
try {
|
|
158
|
+
numbers = new Intl.NumberFormat(locale);
|
|
159
|
+
plurals = new Intl.PluralRules(locale);
|
|
160
|
+
} catch {
|
|
161
|
+
numbers = new Intl.NumberFormat('en');
|
|
162
|
+
plurals = new Intl.PluralRules('en');
|
|
163
|
+
}
|
|
164
|
+
const has = (key: string): boolean => Object.hasOwn(catalog, key);
|
|
165
|
+
|
|
166
|
+
return (key, params) => {
|
|
167
|
+
let template: string | undefined;
|
|
168
|
+
const count = params?.count;
|
|
169
|
+
if (isCount(count)) {
|
|
170
|
+
const form = count === 0 && has(`${key}.zero`) ? 'zero' : plurals.select(count);
|
|
171
|
+
for (const candidate of [`${key}.${form}`, `${key}.other`]) {
|
|
172
|
+
if (has(candidate)) {
|
|
173
|
+
template = catalog[candidate];
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
template ??= has(key) ? catalog[key] : undefined;
|
|
179
|
+
if (template === undefined) return key;
|
|
180
|
+
if (params === undefined) return template;
|
|
181
|
+
return template.replace(/\{([a-zA-Z]+)\}/g, (whole, name: string) => {
|
|
182
|
+
if (!Object.hasOwn(params, name)) return whole;
|
|
183
|
+
const value = params[name];
|
|
184
|
+
return typeof value === 'number' ? numbers.format(value) : String(value);
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const VERSION = '0.1.2';
|
|
2
|
+
|
|
3
|
+
export { NoticeQueue } from './NoticeQueue.js';
|
|
4
|
+
export type { NoticeQueueProps } from './NoticeQueue.js';
|
|
5
|
+
export { MyRecord } from './MyRecord.js';
|
|
6
|
+
export type { MyRecordProps } from './MyRecord.js';
|
|
7
|
+
export { RulesPage } from './RulesPage.js';
|
|
8
|
+
export type { RulesPageProps } from './RulesPage.js';
|
|
9
|
+
export { ReportDialog } from './ReportDialog.js';
|
|
10
|
+
export type { ReportFollowUp, ReportDialogProps, ReportDraft } from './ReportDialog.js';
|
|
11
|
+
export { RestrictedChatLine } from './RestrictedChatLine.js';
|
|
12
|
+
export type { RestrictedChatLineProps } from './RestrictedChatLine.js';
|
|
13
|
+
|
|
14
|
+
export { createPlayerClient, PlayerError, toPlayerError } from './client.js';
|
|
15
|
+
export type { PlayerClient, PlayerClientOptions, PlayerRule, PlayerRules } from './client.js';
|
|
16
|
+
export { fetchTransport, routerTransport } from './transport.js';
|
|
17
|
+
export type {
|
|
18
|
+
FetchTransportOptions,
|
|
19
|
+
RouterRequest,
|
|
20
|
+
RouterResponse,
|
|
21
|
+
RouterTransportOptions,
|
|
22
|
+
Transport,
|
|
23
|
+
TransportRequest,
|
|
24
|
+
TransportResponse,
|
|
25
|
+
} from './transport.js';
|
|
26
|
+
|
|
27
|
+
export { createT, en } from './i18n.js';
|
|
28
|
+
export type { PlayerCatalog, PlayerParams, PlayerT, PlayerText } from './i18n.js';
|
|
29
|
+
export type { PlayerUiProps } from './ui.js';
|
|
30
|
+
export { dayText, timeLeft, untilText } from './format.js';
|
|
31
|
+
|
|
32
|
+
export type { NoticeKind, PlayerNotice, PlayerReporting, ReportDetailResult, ReportFlow, ReportRefusal, ReportResult } from '@chatwarden/core';
|
package/src/reporting.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { PlayerReporting, ReportFlow } from '@chatwarden/core';
|
|
2
|
+
|
|
3
|
+
const FLOWS: readonly ReportFlow[] = ['one_tap', 'reason_first', 'reason_required'];
|
|
4
|
+
|
|
5
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
6
|
+
const text = (v: unknown): v is string => typeof v === 'string';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The reporting payload of `GET /rules` as the dialog may rely on it. Anything that is not the documented shape is dropped: a
|
|
10
|
+
* reason needs a text id and label; the hint and group default to `''` and `asksNote` to false; an unknown flow is `one_tap` (the
|
|
11
|
+
* default); a field the server should not send (a rule mapping, an enabled flag) is never copied. `undefined` when there is no
|
|
12
|
+
* list of reasons at all (an older server, or something that is not the payload).
|
|
13
|
+
*/
|
|
14
|
+
export function readReporting(raw: unknown): PlayerReporting | undefined {
|
|
15
|
+
if (!isObject(raw) || !Array.isArray(raw.reasons)) return undefined;
|
|
16
|
+
const reasons = raw.reasons
|
|
17
|
+
.filter(isObject)
|
|
18
|
+
.filter((r) => text(r.id) && r.id !== '' && text(r.label) && r.label !== '')
|
|
19
|
+
.map((r) => ({ id: r.id as string, label: r.label as string, hint: text(r.hint) ? r.hint : '', group: text(r.group) ? r.group : '', asksNote: r.asksNote === true }));
|
|
20
|
+
const flow = FLOWS.find((f) => f === raw.flow) ?? 'one_tap';
|
|
21
|
+
return { flow, reasons, thanks: text(raw.thanks) ? raw.thanks : '' };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type ReasonItem = PlayerReporting['reasons'][number];
|
|
25
|
+
|
|
26
|
+
/** A heading (`''` for none) and the reasons under it. */
|
|
27
|
+
export interface ReasonGroup {
|
|
28
|
+
name: string;
|
|
29
|
+
items: ReasonItem[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The reasons grouped for the screen: a group is where its first reason is in the list, "Something else" (`other`) is always
|
|
34
|
+
* last, and a group with no name has no heading.
|
|
35
|
+
*/
|
|
36
|
+
export function groupReasons(reasons: readonly ReasonItem[]): ReasonGroup[] {
|
|
37
|
+
const groups: ReasonGroup[] = [];
|
|
38
|
+
for (const r of reasons) {
|
|
39
|
+
if (r.id === 'other') continue;
|
|
40
|
+
const into = groups.find((g) => g.name === r.group);
|
|
41
|
+
if (into === undefined) groups.push({ name: r.group, items: [r] });
|
|
42
|
+
else into.items.push(r);
|
|
43
|
+
}
|
|
44
|
+
const other = reasons.find((r) => r.id === 'other');
|
|
45
|
+
if (other !== undefined) {
|
|
46
|
+
const last = groups.at(-1);
|
|
47
|
+
if (last !== undefined && last.name === '') last.items.push(other);
|
|
48
|
+
else groups.push({ name: '', items: [other] });
|
|
49
|
+
}
|
|
50
|
+
return groups;
|
|
51
|
+
}
|
package/src/safeHref.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The rule for a link address, copied from `@chatwarden/hub-react` (its `isSafeHref`, which guards links in policy documents).
|
|
2
|
+
// The two packages never import each other, so this is a copy; test/safe-href.test.ts runs both on one table.
|
|
3
|
+
|
|
4
|
+
function hasControlOrSpace(text: string): boolean {
|
|
5
|
+
for (const ch of text) {
|
|
6
|
+
const c = ch.codePointAt(0) as number;
|
|
7
|
+
if (c <= 0x20 || (c >= 0x7f && c <= 0x9f)) return true;
|
|
8
|
+
}
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Whether an address may become a link: `https:` (with a host), or a relative address on this site. */
|
|
13
|
+
export function isSafeHref(href: string): boolean {
|
|
14
|
+
// A control character or space inside an address is how `java\tscript:` slips past a scheme check: refuse them all.
|
|
15
|
+
const s = href.trim();
|
|
16
|
+
if (s === '' || hasControlOrSpace(s) || /\s/u.test(s)) return false;
|
|
17
|
+
// A browser reads `\` as `/`, so `/\evil.example` is `//evil.example`, a different site: refuse a backslash anywhere, and a
|
|
18
|
+
// slash that is followed by another slash or by anything that could be read as one.
|
|
19
|
+
if (s.includes('\\') || /^\/[\s/]/u.test(s)) return false;
|
|
20
|
+
// An address that carries an entity is not one an adopter wrote: refuse.
|
|
21
|
+
if (/&#?\w+;/.test(s)) return false;
|
|
22
|
+
const scheme = /^([^/?#]*):/.exec(s);
|
|
23
|
+
if (scheme === null) return true;
|
|
24
|
+
try {
|
|
25
|
+
const url = new URL(s);
|
|
26
|
+
return url.protocol === 'https:' && url.hostname !== '';
|
|
27
|
+
} catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The address for an appeal link (trimmed), or null when it is not safe to link to. `http:` is refused: https only. */
|
|
33
|
+
export function safeHref(url: string | undefined): string | null {
|
|
34
|
+
if (typeof url !== 'string') return null;
|
|
35
|
+
return isSafeHref(url) ? url.trim() : null;
|
|
36
|
+
}
|