@chatwarden/player-react 0.0.0-stage → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +401 -2
  3. package/dist/src/Dialog.d.ts +13 -0
  4. package/dist/src/Dialog.d.ts.map +1 -0
  5. package/dist/src/Dialog.js +83 -0
  6. package/dist/src/Dialog.js.map +1 -0
  7. package/dist/src/MyRecord.d.ts +12 -0
  8. package/dist/src/MyRecord.d.ts.map +1 -0
  9. package/dist/src/MyRecord.js +17 -0
  10. package/dist/src/MyRecord.js.map +1 -0
  11. package/dist/src/NoticeQueue.d.ts +26 -0
  12. package/dist/src/NoticeQueue.d.ts.map +1 -0
  13. package/dist/src/NoticeQueue.js +90 -0
  14. package/dist/src/NoticeQueue.js.map +1 -0
  15. package/dist/src/ReportDialog.d.ts +64 -0
  16. package/dist/src/ReportDialog.d.ts.map +1 -0
  17. package/dist/src/ReportDialog.js +146 -0
  18. package/dist/src/ReportDialog.js.map +1 -0
  19. package/dist/src/RestrictedChatLine.d.ts +12 -0
  20. package/dist/src/RestrictedChatLine.d.ts.map +1 -0
  21. package/dist/src/RestrictedChatLine.js +19 -0
  22. package/dist/src/RestrictedChatLine.js.map +1 -0
  23. package/dist/src/RulesPage.d.ts +10 -0
  24. package/dist/src/RulesPage.d.ts.map +1 -0
  25. package/dist/src/RulesPage.js +17 -0
  26. package/dist/src/RulesPage.js.map +1 -0
  27. package/dist/src/client.d.ts +51 -0
  28. package/dist/src/client.d.ts.map +1 -0
  29. package/dist/src/client.js +98 -0
  30. package/dist/src/client.js.map +1 -0
  31. package/dist/src/constants.d.ts +5 -0
  32. package/dist/src/constants.d.ts.map +1 -0
  33. package/dist/src/constants.js +5 -0
  34. package/dist/src/constants.js.map +1 -0
  35. package/dist/src/format.d.ts +14 -0
  36. package/dist/src/format.d.ts.map +1 -0
  37. package/dist/src/format.js +55 -0
  38. package/dist/src/format.js.map +1 -0
  39. package/dist/src/hooks.d.ts +15 -0
  40. package/dist/src/hooks.d.ts.map +1 -0
  41. package/dist/src/hooks.js +29 -0
  42. package/dist/src/hooks.js.map +1 -0
  43. package/dist/src/i18n.d.ts +153 -0
  44. package/dist/src/i18n.d.ts.map +1 -0
  45. package/dist/src/i18n.js +191 -0
  46. package/dist/src/i18n.js.map +1 -0
  47. package/dist/src/index.d.ts +21 -0
  48. package/dist/src/index.d.ts.map +1 -0
  49. package/dist/src/index.js +11 -0
  50. package/dist/src/index.js.map +1 -0
  51. package/dist/src/reporting.d.ts +20 -0
  52. package/dist/src/reporting.d.ts.map +1 -0
  53. package/dist/src/reporting.js +45 -0
  54. package/dist/src/reporting.js.map +1 -0
  55. package/dist/src/safeHref.d.ts +5 -0
  56. package/dist/src/safeHref.d.ts.map +1 -0
  57. package/dist/src/safeHref.js +41 -0
  58. package/dist/src/safeHref.js.map +1 -0
  59. package/dist/src/transport.d.ts +69 -0
  60. package/dist/src/transport.d.ts.map +1 -0
  61. package/dist/src/transport.js +74 -0
  62. package/dist/src/transport.js.map +1 -0
  63. package/dist/src/ui.d.ts +91 -0
  64. package/dist/src/ui.d.ts.map +1 -0
  65. package/dist/src/ui.js +116 -0
  66. package/dist/src/ui.js.map +1 -0
  67. package/package.json +73 -4
  68. package/src/Dialog.tsx +107 -0
  69. package/src/MyRecord.tsx +60 -0
  70. package/src/NoticeQueue.tsx +203 -0
  71. package/src/ReportDialog.tsx +319 -0
  72. package/src/RestrictedChatLine.tsx +42 -0
  73. package/src/RulesPage.tsx +49 -0
  74. package/src/client.ts +132 -0
  75. package/src/constants.ts +5 -0
  76. package/src/format.ts +53 -0
  77. package/src/hooks.ts +41 -0
  78. package/src/i18n.ts +211 -0
  79. package/src/index.ts +32 -0
  80. package/src/reporting.ts +51 -0
  81. package/src/safeHref.ts +36 -0
  82. package/src/styles.css +500 -0
  83. package/src/transport.ts +134 -0
  84. package/src/ui.tsx +199 -0
package/src/ui.tsx ADDED
@@ -0,0 +1,199 @@
1
+ import { useMemo } from 'react';
2
+ import type { ReactNode } from 'react';
3
+ import { createPortal } from 'react-dom';
4
+ import type { NoticeKind, PlayerNotice } from '@chatwarden/core';
5
+ import { WARNING_DAYS } from './constants.js';
6
+ import type { PlayerError } from './client.js';
7
+ import { dayText, timeLeft, untilText } from './format.js';
8
+ import { createT, en } from './i18n.js';
9
+ import type { PlayerT, PlayerText } from './i18n.js';
10
+ import { safeHref } from './safeHref.js';
11
+
12
+ const DAY = 86_400_000;
13
+
14
+ /** What every component takes besides its own props. */
15
+ export interface PlayerUiProps {
16
+ /** Words: `createT({ ...en, ...yourTranslation }, locale)`. Default: English. */
17
+ t?: PlayerT;
18
+ /** The locale for dates, times and plurals. Default: `en`. */
19
+ locale?: string;
20
+ /** The current time in epoch ms, for "how long is left". Default: `Date.now`. */
21
+ now?: () => number;
22
+ /** An IANA time zone for the times shown (`UTC`, `Europe/Berlin`). Default: the player's own. */
23
+ timeZone?: string;
24
+ /** Forces a theme; without it the player's `prefers-color-scheme` decides. */
25
+ theme?: 'light' | 'dark';
26
+ /** Added to the root element, so a game can theme it from its own stylesheet. */
27
+ className?: string;
28
+ }
29
+
30
+ export interface Ui {
31
+ t: PlayerT;
32
+ locale: string;
33
+ now: () => number;
34
+ timeZone: string | undefined;
35
+ }
36
+
37
+ export function useUi({ t, locale = 'en', now, timeZone }: PlayerUiProps): Ui {
38
+ const words = useMemo(() => t ?? createT(en, locale), [t, locale]);
39
+ return { t: words, locale, now: now ?? Date.now, timeZone };
40
+ }
41
+
42
+ /** What the two components that open a dialog take besides the common props. */
43
+ export interface DialogHostProps {
44
+ /**
45
+ * Draw the dialog inside this element instead of where the component is. The dialog is `position: fixed`, so an ancestor of the
46
+ * component with a `transform`, `filter`, `contain` or `will-change: transform` would make it relative to that ancestor and clip it;
47
+ * pass `document.body` (or a layer of your own) to avoid that. The theme and `className` still apply.
48
+ */
49
+ container?: HTMLElement | null;
50
+ /**
51
+ * Where focus goes when the dialog closes and the element that had focus before it opened is gone. Without it focus is left to the
52
+ * browser, which is the page body.
53
+ */
54
+ returnFocusTo?: () => HTMLElement | null;
55
+ }
56
+
57
+ /** Renders `children` where they are, or into `container` when one is given. */
58
+ export function Portal({ container, children }: { container: HTMLElement | null | undefined; children: ReactNode }) {
59
+ return container === undefined || container === null ? <>{children}</> : createPortal(children, container);
60
+ }
61
+
62
+ /** The root every component renders: the one class all the stylesheet's rules are scoped under. */
63
+ export function Root({ theme, className, children }: { theme?: 'light' | 'dark'; className?: string; children: ReactNode }) {
64
+ return (
65
+ <div className={className === undefined ? 'cw-player' : `cw-player ${className}`} {...(theme === undefined ? {} : { 'data-cw-theme': theme })}>
66
+ {children}
67
+ </div>
68
+ );
69
+ }
70
+
71
+ type CardKind = 'warning' | 'restriction' | 'ban' | 'note';
72
+ const CARD_OF: Readonly<Record<string, CardKind>> = { warning: 'warning', chat: 'restriction', area: 'restriction', ban: 'ban', note: 'note' };
73
+
74
+ /**
75
+ * The referee card: a yellow card for a warning, half yellow and half red for a restriction, red for a ban, and a dashed outline
76
+ * for a note. The same card as the moderator hub's, so a sanction looks the same to staff and player. Decoration: the kind is
77
+ * always written beside it. A kind with no card (a thank-you for a report) draws nothing.
78
+ */
79
+ export function Card({ kind }: { kind: string }) {
80
+ const card = Object.hasOwn(CARD_OF, kind) ? CARD_OF[kind] : undefined;
81
+ return card === undefined ? null : <span className={`cw-card cw-card--${card}`} aria-hidden="true" />;
82
+ }
83
+
84
+ /** The plain sentence for a failed call. The server's own message is never shown. */
85
+ export function errorText(t: PlayerT, e: PlayerError): string {
86
+ if (e.status === 0) return e.code === 'network' ? t('player.error.network') : t('player.error.generic');
87
+ if (e.status === 401) return t('player.error.unauthorized');
88
+ if (e.status === 403) return t('player.error.forbidden');
89
+ if (e.status === 404) return t('player.error.notFound');
90
+ if (e.status === 429) return e.retryAfter === undefined ? t('player.error.rateLimited') : t('player.error.rateLimited.after', { count: e.retryAfter });
91
+ if (e.status >= 500) return t('player.error.server');
92
+ return t('player.error.generic');
93
+ }
94
+
95
+ /** A failed load: what could not be loaded, why, and a way to try again. */
96
+ export function ErrorBlock({ ui, what, error, onRetry }: { ui: Ui; what: string; error: PlayerError; onRetry: () => void }) {
97
+ return (
98
+ <div className="cw-error" role="alert">
99
+ <p>
100
+ <strong>{what}</strong> {errorText(ui.t, error)}
101
+ </p>
102
+ <button type="button" className="cw-btn" onClick={onRetry}>
103
+ {ui.t('player.state.retry')}
104
+ </button>
105
+ </div>
106
+ );
107
+ }
108
+
109
+ /** "Think this is a mistake?", when the adopter gave an address that is safe to link to. */
110
+ export function AppealLink({ ui, href }: { ui: Ui; href: string | undefined }) {
111
+ const safe = safeHref(href);
112
+ if (safe === null) return null;
113
+ return (
114
+ <p className="cw-appeal">
115
+ <a className="cw-link" href={safe} target="_blank" rel="noopener noreferrer">
116
+ {ui.t('player.notice.appeal')}
117
+ </a>
118
+ </p>
119
+ );
120
+ }
121
+
122
+ const TITLE: Readonly<Record<string, PlayerText>> = {
123
+ warning: 'player.notice.title.warning',
124
+ chat: 'player.notice.title.chat',
125
+ area: 'player.notice.title.area',
126
+ ban: 'player.notice.title.ban',
127
+ report_outcome: 'player.notice.title.report_outcome',
128
+ };
129
+ const BODY: Readonly<Record<string, PlayerText>> = {
130
+ warning: 'player.notice.body.warning',
131
+ chat: 'player.notice.body.chat',
132
+ area: 'player.notice.body.area',
133
+ ban: 'player.notice.body.ban',
134
+ report_outcome: 'player.notice.body.report_outcome',
135
+ };
136
+ const KIND: Readonly<Record<string, PlayerText>> = {
137
+ warning: 'player.record.kind.warning',
138
+ chat: 'player.record.kind.chat',
139
+ area: 'player.record.kind.area',
140
+ ban: 'player.record.kind.ban',
141
+ };
142
+
143
+ export const titleKey = (kind: NoticeKind | string): PlayerText => (Object.hasOwn(TITLE, kind) ? (TITLE[kind] as PlayerText) : 'player.notice.title.fallback');
144
+ export const bodyKey = (kind: NoticeKind | string): PlayerText => (Object.hasOwn(BODY, kind) ? (BODY[kind] as PlayerText) : 'player.notice.body.fallback');
145
+ export const kindKey = (kind: NoticeKind | string): PlayerText => (Object.hasOwn(KIND, kind) ? (KIND[kind] as PlayerText) : 'player.record.kind.fallback');
146
+
147
+ /** A sanction a player can contest (a thank-you for a report is not one). */
148
+ export const isSanction = (kind: string): boolean => kind === 'warning' || kind === 'chat' || kind === 'area' || kind === 'ban';
149
+
150
+ /** The facts of a notice, as a term list: the rule, when it was given (the record only), until when, and the reference code. */
151
+ export function Facts({ ui, notice, record = false }: { ui: Ui; notice: PlayerNotice; record?: boolean }) {
152
+ const { t, locale, timeZone } = ui;
153
+ const rows: Array<[string, ReactNode]> = [];
154
+ if (notice.ruleLabel !== null) rows.push([t('player.notice.rule'), notice.ruleLabel]);
155
+ if (record) rows.push([t('player.notice.given'), untilText(notice.createdAt, locale, timeZone)]);
156
+ if (notice.kind === 'warning') {
157
+ // A warning has no end of its own: it stays on the record for 90 days from when it was made.
158
+ rows.push([t('player.notice.onRecordUntil'), untilText(notice.createdAt + WARNING_DAYS * DAY, locale, timeZone)]);
159
+ } else if (isSanction(notice.kind) || notice.until !== null) {
160
+ if (notice.until === null) rows.push([t('player.notice.until'), t('player.notice.noEnd')]);
161
+ else {
162
+ const when = untilText(notice.until, locale, timeZone);
163
+ const left = timeLeft(notice.until, ui.now(), locale);
164
+ rows.push(left === null ? [t('player.notice.ended'), when] : [t('player.notice.until'), t('player.notice.untilLeft', { when, left })]);
165
+ }
166
+ }
167
+ if (notice.refCode !== null) rows.push([t('player.notice.ref'), notice.refCode]);
168
+ if (rows.length === 0) return null;
169
+ return (
170
+ <dl className="cw-facts">
171
+ {rows.map(([term, value]) => (
172
+ <div key={term} className="cw-fact">
173
+ <dt>{term}</dt>
174
+ <dd>{value}</dd>
175
+ </div>
176
+ ))}
177
+ </dl>
178
+ );
179
+ }
180
+
181
+ /** The player's own lines that led to a notice, as text with their day. Never HTML. */
182
+ export function Lines({ ui, notice, id }: { ui: Ui; notice: PlayerNotice; id: string }) {
183
+ if (notice.excerpt.length === 0) return null;
184
+ return (
185
+ <>
186
+ <p id={id} className="cw-lines-label">
187
+ {ui.t('player.notice.lines')}
188
+ </p>
189
+ <ul className="cw-lines" aria-labelledby={id}>
190
+ {notice.excerpt.map((line, i) => (
191
+ <li key={i}>
192
+ <span className="cw-line-day">{dayText(line.day, ui.locale)}</span>
193
+ <span className="cw-line-text">{line.text}</span>
194
+ </li>
195
+ ))}
196
+ </ul>
197
+ </>
198
+ );
199
+ }