g-comments 0.0.0-stage → 0.2.0

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/dist/react.js ADDED
@@ -0,0 +1,2745 @@
1
+ "use client";
2
+ import { createTranslator, PACKAGE_NAME, currentUrlWithoutHash, findComment, createComposerTimer, normalizeForum, resolveLocale, clientRegistry, STORAGE_PREFIX, isCommentsError, CommentsError, encodeRef, defaultPageUrl } from './chunk-WACVXJHD.js';
3
+ import { createContext, useState, useRef, useCallback, useEffect, useMemo, Fragment as Fragment$1, useId, useContext, useLayoutEffect, useSyncExternalStore } from 'react';
4
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
5
+
6
+ var useIsomorphicLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
7
+ function toCommentsError(error) {
8
+ if (isCommentsError(error)) {
9
+ return error;
10
+ }
11
+ return new CommentsError("unknown", error instanceof Error ? error.message : String(error), {
12
+ cause: error
13
+ });
14
+ }
15
+ function noop() {
16
+ }
17
+ function rootNodeOf(element) {
18
+ const root = element.getRootNode();
19
+ return root instanceof Document || root instanceof ShadowRoot ? root : element.ownerDocument;
20
+ }
21
+ function isLocalPage() {
22
+ if (typeof window === "undefined") {
23
+ return false;
24
+ }
25
+ const host = window.location.hostname;
26
+ return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host.endsWith(".localhost") || host.endsWith(".test");
27
+ }
28
+ function readDraft(key) {
29
+ try {
30
+ return globalThis.localStorage?.getItem(key) ?? "";
31
+ } catch {
32
+ return "";
33
+ }
34
+ }
35
+ function writeDraft(key, value) {
36
+ try {
37
+ if (value.trim() === "") {
38
+ globalThis.localStorage?.removeItem(key);
39
+ } else {
40
+ globalThis.localStorage?.setItem(key, value);
41
+ }
42
+ } catch {
43
+ }
44
+ }
45
+ var tickListeners = /* @__PURE__ */ new Set();
46
+ var tickTimer;
47
+ function onMinuteTick(listener) {
48
+ tickListeners.add(listener);
49
+ tickTimer ??= setInterval(() => {
50
+ for (const tick of [...tickListeners]) {
51
+ tick();
52
+ }
53
+ }, 6e4);
54
+ return () => {
55
+ tickListeners.delete(listener);
56
+ if (tickListeners.size === 0) {
57
+ clearInterval(tickTimer);
58
+ tickTimer = void 0;
59
+ }
60
+ };
61
+ }
62
+ var UNITS = [
63
+ ["year", 31536e3],
64
+ ["month", 2592e3],
65
+ ["week", 604800],
66
+ ["day", 86400],
67
+ ["hour", 3600],
68
+ ["minute", 60]
69
+ ];
70
+ function formatRelative(iso, now, locale) {
71
+ const seconds = Math.round((Date.parse(iso) - now) / 1e3);
72
+ const format = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
73
+ for (const [unit, size] of UNITS) {
74
+ if (Math.abs(seconds) >= size) {
75
+ return format.format(Math.round(seconds / size), unit);
76
+ }
77
+ }
78
+ return format.format(0, "second");
79
+ }
80
+ function formatAbsolute(iso, locale) {
81
+ return new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" }).format(
82
+ new Date(iso)
83
+ );
84
+ }
85
+
86
+ // src/react/context.ts
87
+ var CommentsContext = createContext(null);
88
+ var defaultTranslator = createTranslator();
89
+ function useCommentsContext(forum) {
90
+ const value = useOptionalCommentsContext(forum);
91
+ if (!value) {
92
+ throw new Error(
93
+ `${PACKAGE_NAME}: render this inside <DiscourseComments forum="\u2026"> or pass a \`forum\` prop`
94
+ );
95
+ }
96
+ return value;
97
+ }
98
+ function useOptionalCommentsContext(forum) {
99
+ const context = useContext(CommentsContext);
100
+ const standalone = useMemo(() => {
101
+ if (forum === void 0 || context && normalizeForum(forum).base === context.client.forum) {
102
+ return null;
103
+ }
104
+ return {
105
+ client: clientRegistry.acquire({ forum }),
106
+ t: context?.t ?? defaultTranslator,
107
+ locale: context?.locale ?? resolveLocale(void 0),
108
+ messages: context?.messages,
109
+ theme: context?.theme ?? "auto",
110
+ appearance: context?.appearance,
111
+ debug: context?.debug ?? false,
112
+ emit: context?.emit ?? noop
113
+ };
114
+ }, [forum, context]);
115
+ return standalone ?? context;
116
+ }
117
+ function useClient() {
118
+ const context = useContext(CommentsContext);
119
+ if (!context) {
120
+ throw new Error(`${PACKAGE_NAME}: useClient() needs a <DiscourseComments> provider`);
121
+ }
122
+ return context.client;
123
+ }
124
+ var QUIET_AUTH_ERRORS = /* @__PURE__ */ new Set(["cancelled", "invalid_key"]);
125
+ function DiscourseComments(props) {
126
+ const { children, appearance, locale, messages, theme = "auto", debug = false, onEvent } = props;
127
+ const explicitClient = props.client;
128
+ const forum = props.forum;
129
+ const creation = props.forum === void 0 ? null : {
130
+ forum: props.forum,
131
+ fetch: props.fetch,
132
+ headers: props.headers,
133
+ storage: props.storage,
134
+ clientId: props.clientId,
135
+ locale,
136
+ debug
137
+ };
138
+ const client = useMemo(
139
+ () => explicitClient ?? clientRegistry.acquire(creation),
140
+ [explicitClient, forum]
141
+ );
142
+ const messagesKey = messages ? JSON.stringify(messages) : "";
143
+ const t = useMemo(() => createTranslator(locale, messages), [locale, messagesKey]);
144
+ const onEventRef = useRef(onEvent);
145
+ useEffect(() => {
146
+ onEventRef.current = onEvent;
147
+ });
148
+ const value = useMemo(
149
+ () => ({
150
+ client,
151
+ t,
152
+ locale: resolveLocale(locale),
153
+ messages,
154
+ theme,
155
+ appearance,
156
+ debug,
157
+ emit: (event) => onEventRef.current?.(event)
158
+ }),
159
+ [client, t, locale, messagesKey, theme, appearance, debug]
160
+ );
161
+ useEffect(() => {
162
+ let previous = client.auth.state();
163
+ return client.auth.onChange((state) => {
164
+ const wasSignedIn = previous.status === "signed-in";
165
+ if (state.status === "signed-in" && state.user && !wasSignedIn) {
166
+ onEventRef.current?.({ type: "auth.signedIn", user: state.user });
167
+ } else if (state.status === "anonymous" && wasSignedIn) {
168
+ onEventRef.current?.({
169
+ type: "auth.signedOut",
170
+ expired: state.error?.type === "invalid_key"
171
+ });
172
+ }
173
+ const failure = state.error;
174
+ if (failure && failure !== previous.error && !QUIET_AUTH_ERRORS.has(failure.type)) {
175
+ onEventRef.current?.({ type: "error", action: "signIn", error: failure });
176
+ }
177
+ previous = state;
178
+ });
179
+ }, [client]);
180
+ return /* @__PURE__ */ jsx(CommentsContext.Provider, { value, children });
181
+ }
182
+
183
+ // src/react/appearance.ts
184
+ function cx(...classes) {
185
+ return classes.filter(Boolean).join(" ");
186
+ }
187
+ function mergeAppearance(...layers) {
188
+ const variables = {};
189
+ const elements = {};
190
+ for (const layer of layers) {
191
+ if (!layer) {
192
+ continue;
193
+ }
194
+ Object.assign(variables, layer.variables);
195
+ for (const [name, value] of Object.entries(layer.elements ?? {})) {
196
+ elements[name] = cx(elements[name], value) || void 0;
197
+ }
198
+ }
199
+ return { variables, elements };
200
+ }
201
+ function variablesStyle(variables) {
202
+ const style = {};
203
+ for (const [name, value] of Object.entries(variables ?? {})) {
204
+ if (value !== void 0 && value !== "") {
205
+ style[`--gc-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`] = value;
206
+ }
207
+ }
208
+ return style;
209
+ }
210
+ function Icon({ children }) {
211
+ return /* @__PURE__ */ jsx(
212
+ "svg",
213
+ {
214
+ className: "gc-icon",
215
+ width: "16",
216
+ height: "16",
217
+ viewBox: "0 0 24 24",
218
+ fill: "none",
219
+ stroke: "currentColor",
220
+ strokeWidth: "2",
221
+ strokeLinecap: "round",
222
+ strokeLinejoin: "round",
223
+ "aria-hidden": "true",
224
+ focusable: "false",
225
+ children
226
+ }
227
+ );
228
+ }
229
+ function HeartIcon({ filled = false }) {
230
+ return /* @__PURE__ */ jsx(Icon, { children: /* @__PURE__ */ jsx(
231
+ "path",
232
+ {
233
+ fill: filled ? "currentColor" : "none",
234
+ d: "M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.7l-1-1.1a5.5 5.5 0 0 0-7.8 7.8l1 1.1L12 21l7.8-7.5 1-1.1a5.5 5.5 0 0 0 0-7.8z"
235
+ }
236
+ ) });
237
+ }
238
+ function ReplyIcon() {
239
+ return /* @__PURE__ */ jsxs(Icon, { children: [
240
+ /* @__PURE__ */ jsx("path", { d: "M9 17 4 12l5-5" }),
241
+ /* @__PURE__ */ jsx("path", { d: "M20 18v-2a4 4 0 0 0-4-4H4" })
242
+ ] });
243
+ }
244
+ function MoreIcon() {
245
+ return /* @__PURE__ */ jsxs(Icon, { children: [
246
+ /* @__PURE__ */ jsx("circle", { cx: "5", cy: "12", r: "1" }),
247
+ /* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "1" }),
248
+ /* @__PURE__ */ jsx("circle", { cx: "19", cy: "12", r: "1" })
249
+ ] });
250
+ }
251
+ function PinIcon() {
252
+ return /* @__PURE__ */ jsxs(Icon, { children: [
253
+ /* @__PURE__ */ jsx("path", { d: "M12 17v5" }),
254
+ /* @__PURE__ */ jsx("path", { d: "M9 10.8V4h6v6.8l2 3.2H7z" })
255
+ ] });
256
+ }
257
+ function BoldIcon() {
258
+ return /* @__PURE__ */ jsx(Icon, { children: /* @__PURE__ */ jsx("path", { d: "M7 5h6a3.5 3.5 0 0 1 0 7H7zM7 12h7a3.5 3.5 0 0 1 0 7H7z" }) });
259
+ }
260
+ function ItalicIcon() {
261
+ return /* @__PURE__ */ jsx(Icon, { children: /* @__PURE__ */ jsx("path", { d: "M19 4h-9M14 20H5M15 4 9 20" }) });
262
+ }
263
+ function LinkIcon() {
264
+ return /* @__PURE__ */ jsxs(Icon, { children: [
265
+ /* @__PURE__ */ jsx("path", { d: "M10 13a5 5 0 0 0 7.5.5l3-3a5 5 0 0 0-7-7l-1.7 1.7" }),
266
+ /* @__PURE__ */ jsx("path", { d: "M14 11a5 5 0 0 0-7.5-.5l-3 3a5 5 0 0 0 7 7l1.7-1.7" })
267
+ ] });
268
+ }
269
+ function CodeIcon() {
270
+ return /* @__PURE__ */ jsx(Icon, { children: /* @__PURE__ */ jsx("path", { d: "m16 18 6-6-6-6M8 6l-6 6 6 6" }) });
271
+ }
272
+ function QuoteIcon() {
273
+ return /* @__PURE__ */ jsx(Icon, { children: /* @__PURE__ */ jsx("path", { d: "M3 21c3 0 7-1 7-8V5H3v7h4c0 3-1 5-4 6zM14 21c3 0 7-1 7-8V5h-7v7h4c0 3-1 5-4 6z" }) });
274
+ }
275
+ function ImageIcon() {
276
+ return /* @__PURE__ */ jsxs(Icon, { children: [
277
+ /* @__PURE__ */ jsx("rect", { x: "3", y: "3", width: "18", height: "18", rx: "2" }),
278
+ /* @__PURE__ */ jsx("circle", { cx: "9", cy: "9", r: "2" }),
279
+ /* @__PURE__ */ jsx("path", { d: "m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21" })
280
+ ] });
281
+ }
282
+ function BackIcon() {
283
+ return /* @__PURE__ */ jsx(Icon, { children: /* @__PURE__ */ jsx("path", { d: "m15 18-6-6 6-6" }) });
284
+ }
285
+
286
+ // src/react/forum-theme.ts
287
+ var FORUM_ROOT = '.gc-root[data-gc-theme="forum"]';
288
+ var FORUM_STYLED_ROOT = '.gc-root--styled[data-gc-theme="forum"]';
289
+ var TOKEN_SOURCES = [
290
+ ["fg", "primary"],
291
+ ["fg-muted", "primary-medium"],
292
+ ["bg", "secondary"],
293
+ ["bg-subtle", "primary-very-low"],
294
+ ["border", "primary-low"],
295
+ ["accent", "tertiary"],
296
+ ["accent-fg", "secondary"],
297
+ ["danger", "danger"]
298
+ ];
299
+ var SAFE_NAME = /^[\w-]+$/;
300
+ var SAFE_VALUE = /^[^;{}<>]+$/;
301
+ var SAFE_FAMILY = /^[^"'\;{}<>]+$/;
302
+ var FONT_SOURCE = /^url\("https?:\/\/[^"\s<>]+"\)(?:\s+format\("[\w-]+"\))?$|^local\("[^"\\<>]+"\)$/;
303
+ var FONT_WEIGHT = /^(?:[1-9]\d{0,2}|normal|bold)$/;
304
+ var SCHEME_TYPES = /* @__PURE__ */ new Set(["light", "dark"]);
305
+ var STYLE_ATTRIBUTE = "data-gc-forum-theme";
306
+ var FONTS_ATTRIBUTE = "data-gc-forum-fonts";
307
+ function colorDeclarations(scheme) {
308
+ let css = "";
309
+ for (const [name, value] of Object.entries(scheme.colors)) {
310
+ if (SAFE_NAME.test(name) && SAFE_VALUE.test(value)) {
311
+ css += `--gc-forum-${name}:${value};`;
312
+ }
313
+ }
314
+ if (SCHEME_TYPES.has(scheme.type)) {
315
+ css += `color-scheme:${scheme.type};`;
316
+ }
317
+ return css;
318
+ }
319
+ function fontDeclarations(fonts) {
320
+ let css = "";
321
+ for (const [role, font] of Object.entries(fonts)) {
322
+ if (font && SAFE_NAME.test(role) && SAFE_VALUE.test(font.stack)) {
323
+ css += `--gc-forum-font-${role}:${font.stack};`;
324
+ }
325
+ }
326
+ return css;
327
+ }
328
+ function tokenDeclarations(theme) {
329
+ let css = "";
330
+ for (const [token, color] of TOKEN_SOURCES) {
331
+ if (color in theme.schemes.light.colors) {
332
+ css += `--_gc-${token}:var(--gc-${token},var(--gc-forum-${color}));`;
333
+ }
334
+ }
335
+ if (theme.fonts.body) {
336
+ css += "--_gc-font:var(--gc-font,var(--gc-forum-font-body));";
337
+ }
338
+ return css;
339
+ }
340
+ function forumThemeCss(theme) {
341
+ const { light, dark } = theme.schemes;
342
+ let css = `${FORUM_ROOT}{${colorDeclarations(light)}${fontDeclarations(theme.fonts)}}
343
+ `;
344
+ css += `${FORUM_STYLED_ROOT}{${tokenDeclarations(theme)}}
345
+ `;
346
+ if (dark) {
347
+ css += `@media (prefers-color-scheme: dark){${FORUM_ROOT}{${colorDeclarations(dark)}}}
348
+ `;
349
+ }
350
+ return css;
351
+ }
352
+ function forumFontFacesCss(theme) {
353
+ let css = "";
354
+ for (const font of Object.values(theme.fonts)) {
355
+ for (const face of font?.faces ?? []) {
356
+ const weight = String(face.weight);
357
+ if (SAFE_FAMILY.test(face.family) && FONT_SOURCE.test(face.src) && FONT_WEIGHT.test(weight)) {
358
+ css += `@font-face{font-family:"${face.family}";src:${face.src};font-weight:${weight};font-display:swap;}
359
+ `;
360
+ }
361
+ }
362
+ }
363
+ return css;
364
+ }
365
+ function forumStylesheetUrls(theme, forum) {
366
+ const secure = forum.startsWith("https:");
367
+ const urls = [];
368
+ for (const href of theme.stylesheets) {
369
+ let url;
370
+ try {
371
+ url = new URL(href);
372
+ } catch {
373
+ continue;
374
+ }
375
+ if (url.protocol === "https:" || !secure && url.protocol === "http:") {
376
+ urls.push(url.href);
377
+ }
378
+ }
379
+ return urls;
380
+ }
381
+ function documentOf(root) {
382
+ return root.nodeType === 9 ? root : root.ownerDocument;
383
+ }
384
+ function replaceNodes(container, attribute, forum, key, create) {
385
+ let current = false;
386
+ for (const node of container.querySelectorAll(`[${attribute}]`)) {
387
+ const value = node.getAttribute(attribute);
388
+ if (value === key) {
389
+ current = true;
390
+ } else if (value?.startsWith(`${forum}#`)) {
391
+ node.remove();
392
+ }
393
+ }
394
+ if (!current) {
395
+ container.append(...create());
396
+ }
397
+ }
398
+ function applyForumTheme(root, theme, forum) {
399
+ const doc = documentOf(root);
400
+ const container = root.nodeType === 9 ? doc.head : root;
401
+ const key = `${forum}#${theme.version}`;
402
+ replaceNodes(container, STYLE_ATTRIBUTE, forum, key, () => {
403
+ const style = doc.createElement("style");
404
+ style.setAttribute(STYLE_ATTRIBUTE, key);
405
+ style.textContent = forumThemeCss(theme);
406
+ const links = forumStylesheetUrls(theme, forum).map((href) => {
407
+ const link = doc.createElement("link");
408
+ link.rel = "stylesheet";
409
+ link.href = href;
410
+ link.setAttribute(STYLE_ATTRIBUTE, key);
411
+ return link;
412
+ });
413
+ return [style, ...links];
414
+ });
415
+ const faces = forumFontFacesCss(theme);
416
+ if (faces) {
417
+ replaceNodes(doc.head, FONTS_ATTRIBUTE, forum, key, () => {
418
+ const style = doc.createElement("style");
419
+ style.setAttribute(FONTS_ATTRIBUTE, key);
420
+ style.textContent = faces;
421
+ return [style];
422
+ });
423
+ }
424
+ }
425
+ var loaded = /* @__PURE__ */ new WeakMap();
426
+ function initial(client) {
427
+ return { client, config: loaded.get(client) ?? null, error: null };
428
+ }
429
+ function useConfig(client) {
430
+ const [stored, setState] = useState(() => initial(client));
431
+ let state = stored;
432
+ if (stored.client !== client) {
433
+ state = initial(client);
434
+ setState(state);
435
+ }
436
+ const [attempt, setAttempt] = useState(0);
437
+ useEffect(() => {
438
+ let active = true;
439
+ const update = (next) => setState((current) => active && current.client === client ? next(current) : current);
440
+ const load = () => client.config().then(
441
+ (config) => {
442
+ loaded.set(client, config);
443
+ update(
444
+ (current) => current.config === config && !current.error ? current : { client, config, error: null }
445
+ );
446
+ },
447
+ (error) => {
448
+ update((current) => ({ ...current, error: toCommentsError(error) }));
449
+ }
450
+ );
451
+ void load();
452
+ const stop = client.auth.onChange(() => void load());
453
+ return () => {
454
+ active = false;
455
+ stop();
456
+ };
457
+ }, [client, attempt]);
458
+ const reload = useCallback(() => setAttempt((count) => count + 1), []);
459
+ return { config: state.config, error: state.error, reload };
460
+ }
461
+
462
+ // src/react/hooks/use-commenter.ts
463
+ function useCommenter(options = {}) {
464
+ const { client } = useCommentsContext(options.forum);
465
+ const { config } = useConfig(client);
466
+ return useCommenterFor(client, config);
467
+ }
468
+ function useCommenterFor(client, config) {
469
+ const { auth } = client;
470
+ const subscribe = useCallback((onChange) => auth.onChange(onChange), [auth]);
471
+ const getState = useCallback(() => auth.state(), [auth]);
472
+ const state = useSyncExternalStore(subscribe, getState, getState);
473
+ const actions = useMemo(
474
+ () => ({
475
+ signIn: (signInOptions) => auth.signIn(signInOptions),
476
+ cancelSignIn: () => auth.cancelSignIn(),
477
+ signOut: () => auth.signOut()
478
+ }),
479
+ [auth]
480
+ );
481
+ const providers = config?.auth_providers ?? auth.providers;
482
+ return useMemo(
483
+ () => ({
484
+ user: state.user,
485
+ status: state.status,
486
+ providers,
487
+ degraded: state.degraded,
488
+ error: state.error,
489
+ ...actions
490
+ }),
491
+ [state, providers, actions]
492
+ );
493
+ }
494
+ var loaded2 = /* @__PURE__ */ new WeakMap();
495
+ function initial2(client) {
496
+ return { client, theme: loaded2.get(client) ?? null, error: null };
497
+ }
498
+ function useForumThemeFor(client, enabled) {
499
+ const [stored, setState] = useState(() => initial2(client));
500
+ let state = stored;
501
+ if (stored.client !== client) {
502
+ state = initial2(client);
503
+ setState(state);
504
+ }
505
+ useEffect(() => {
506
+ if (!enabled) {
507
+ return;
508
+ }
509
+ let active = true;
510
+ client.theme().then(
511
+ (theme) => {
512
+ loaded2.set(client, theme);
513
+ if (active) {
514
+ setState(
515
+ (current) => current.client === client && current.theme !== theme ? { client, theme, error: null } : current
516
+ );
517
+ }
518
+ },
519
+ (error) => {
520
+ if (active) {
521
+ setState(
522
+ (current) => current.client === client ? { ...current, error: toCommentsError(error) } : current
523
+ );
524
+ }
525
+ }
526
+ );
527
+ return () => {
528
+ active = false;
529
+ };
530
+ }, [client, enabled]);
531
+ return { theme: state.theme, error: state.error };
532
+ }
533
+ function useForumTheme() {
534
+ const { client } = useCommentsContext();
535
+ return useForumThemeFor(client, true);
536
+ }
537
+ function useThread(options = {}) {
538
+ const { client } = useCommentsContext(options.forum);
539
+ return useThreadFor(client, options);
540
+ }
541
+ function useThreadFor(client, { identifier, url, title, sort }, onOpenError) {
542
+ const [attempt, setAttempt] = useState(0);
543
+ const [opened, setOpened] = useState(
544
+ { thread: null, error: null }
545
+ );
546
+ const pageKey = url === void 0 ? currentUrlWithoutHash() ?? "" : "";
547
+ const latest = useRef({ title, sort, onOpenError });
548
+ latest.current = { title, sort, onOpenError };
549
+ const requestedSort = useRef(sort);
550
+ useEffect(() => {
551
+ let active = true;
552
+ let handle = null;
553
+ setOpened((current) => current.thread || current.error ? { thread: null, error: null } : current);
554
+ queueMicrotask(() => {
555
+ if (!active) {
556
+ return;
557
+ }
558
+ const { title: openTitle, sort: openSort } = latest.current;
559
+ requestedSort.current = openSort;
560
+ client.threads.open({ identifier, url, title: openTitle, sort: openSort }).then(
561
+ (thread2) => {
562
+ if (!active) {
563
+ thread2.dispose();
564
+ return;
565
+ }
566
+ handle = thread2;
567
+ setOpened({ thread: thread2, error: null });
568
+ },
569
+ (error) => {
570
+ if (active) {
571
+ const failure = toCommentsError(error);
572
+ setOpened({ thread: null, error: failure });
573
+ latest.current.onOpenError?.(failure);
574
+ }
575
+ }
576
+ );
577
+ });
578
+ return () => {
579
+ active = false;
580
+ handle?.dispose();
581
+ };
582
+ }, [client, identifier, url, pageKey, attempt]);
583
+ const { thread } = opened;
584
+ useEffect(() => {
585
+ if (thread && sort !== void 0 && sort !== requestedSort.current) {
586
+ requestedSort.current = sort;
587
+ void thread.setSort(sort).catch(() => {
588
+ });
589
+ }
590
+ }, [thread, sort]);
591
+ const subscribe = useCallback(
592
+ (onChange) => thread ? thread.subscribe(onChange) : () => {
593
+ },
594
+ [thread]
595
+ );
596
+ const getSnapshot = useCallback(() => thread?.snapshot() ?? null, [thread]);
597
+ const snapshot = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
598
+ const retry = useCallback(() => {
599
+ if (thread) {
600
+ void thread.load().catch(() => {
601
+ });
602
+ } else {
603
+ setAttempt((count) => count + 1);
604
+ }
605
+ }, [thread]);
606
+ return { thread, snapshot, error: opened.error, retry };
607
+ }
608
+ var RootContext = createContext(null);
609
+ function useRootContext(component = "This component") {
610
+ const value = useContext(RootContext);
611
+ if (!value) {
612
+ throw new Error(`${PACKAGE_NAME}: ${component} must be rendered inside <Comments.Root>`);
613
+ }
614
+ return value;
615
+ }
616
+ function Root(props) {
617
+ const context = useOptionalCommentsContext(props.forum);
618
+ if (!context) {
619
+ return /* @__PURE__ */ jsx("section", { className: cx("gc-root", "gc-root--unconfigured", props.className), "data-gc-unconfigured": "", children: /* @__PURE__ */ jsx("p", { className: "gc-notice", children: `${PACKAGE_NAME}: render <Comments> inside <DiscourseComments forum="\u2026"> or pass a forum prop.` }) });
620
+ }
621
+ return /* @__PURE__ */ jsx(ConfiguredRoot, { ...props });
622
+ }
623
+ function ConfiguredRoot(props) {
624
+ const {
625
+ forum,
626
+ identifier,
627
+ url,
628
+ title,
629
+ sort,
630
+ maxDepth = 2,
631
+ signIn: signInMode = "inline",
632
+ onSignInRequired,
633
+ readOnly = false,
634
+ highlight,
635
+ guidelinesUrl,
636
+ onPosted,
637
+ appearance,
638
+ unstyled = false,
639
+ theme: themeProp,
640
+ locale: localeProp,
641
+ className,
642
+ children
643
+ } = props;
644
+ const context = useCommentsContext(forum);
645
+ const { client, emit } = context;
646
+ const rootRef = useRef(null);
647
+ const t = useMemo(
648
+ () => localeProp ? createTranslator(localeProp, context.messages) : context.t,
649
+ [localeProp, context.messages, context.t]
650
+ );
651
+ const locale = localeProp ? resolveLocale(localeProp) : context.locale;
652
+ const theme = themeProp ?? context.theme;
653
+ const { variables, elements } = useMemo(
654
+ () => mergeAppearance(context.appearance, appearance),
655
+ [context.appearance, appearance]
656
+ );
657
+ const threadIdRef = useRef(void 0);
658
+ const reportError = useCallback(
659
+ (action, error) => emit({ type: "error", action, error: toCommentsError(error), threadId: threadIdRef.current }),
660
+ [emit]
661
+ );
662
+ const { thread, snapshot, error: openError, retry: retryThread } = useThreadFor(
663
+ client,
664
+ { identifier, url, title, sort },
665
+ (error) => reportError("open", error)
666
+ );
667
+ threadIdRef.current = thread?.id;
668
+ const { config, error: configError, reload: reloadConfig } = useConfig(client);
669
+ const commenter = useCommenterFor(client, config);
670
+ const forumTheme = useForumThemeFor(client, theme === "forum");
671
+ useEffect(() => {
672
+ const element = rootRef.current;
673
+ if (theme === "forum" && forumTheme.theme && element) {
674
+ applyForumTheme(rootNodeOf(element), forumTheme.theme, client.forum);
675
+ }
676
+ }, [theme, forumTheme.theme, client.forum]);
677
+ const themeError = theme === "forum" ? forumTheme.error : null;
678
+ useEffect(() => {
679
+ if (themeError) {
680
+ reportError("theme", themeError);
681
+ }
682
+ }, [themeError, reportError]);
683
+ const retry = useCallback(() => {
684
+ retryThread();
685
+ if (configError) {
686
+ reloadConfig();
687
+ }
688
+ }, [retryThread, configError, reloadConfig]);
689
+ const loadError = snapshot?.status === "error" ? snapshot.error : null;
690
+ useEffect(() => {
691
+ if (loadError) {
692
+ reportError("load", loadError);
693
+ }
694
+ }, [loadError, reportError]);
695
+ const userId = commenter.user?.id ?? null;
696
+ const [authEpoch, setAuthEpoch] = useState(
697
+ { userId, thread: null }
698
+ );
699
+ if (authEpoch.userId !== userId) {
700
+ setAuthEpoch({ userId, thread: snapshot?.thread ?? null });
701
+ }
702
+ const permissionsFresh = !!snapshot && snapshot.thread !== authEpoch.thread;
703
+ const signedIn = commenter.status === "signed-in";
704
+ const closed = snapshot?.thread.closed ?? false;
705
+ const writable = !readOnly && !commenter.degraded;
706
+ const open = !!snapshot && snapshot.status !== "error" && writable && !closed;
707
+ const commentingDenied = open && signedIn && permissionsFresh && !snapshot.thread.can_comment;
708
+ const canCompose = open && !commentingDenied && (signedIn || signInMode === "on-submit");
709
+ const canReply = open && !commentingDenied;
710
+ const [focusView, setFocusView] = useState(null);
711
+ const viewRequest = useRef(0);
712
+ const [highlightId, setHighlightId] = useState(null);
713
+ const focusRequest = useRef(null);
714
+ const [, setFocusTick] = useState(0);
715
+ const focusComment = useCallback((commentId, options) => {
716
+ focusRequest.current = { id: commentId, scroll: options?.scroll ?? false };
717
+ setFocusTick((tick) => tick + 1);
718
+ }, []);
719
+ useEffect(() => {
720
+ const request = focusRequest.current;
721
+ const element = request && rootRef.current?.querySelector(`#gc-${request.id}`);
722
+ if (!request || !element) {
723
+ return;
724
+ }
725
+ focusRequest.current = null;
726
+ if (request.scroll) {
727
+ element.scrollIntoView?.({ block: "center" });
728
+ }
729
+ element.focus({ preventScroll: request.scroll });
730
+ });
731
+ useEffect(() => {
732
+ viewRequest.current++;
733
+ setFocusView(null);
734
+ }, [thread]);
735
+ const continueThread = useCallback(
736
+ async (commentId) => {
737
+ if (!thread) {
738
+ return;
739
+ }
740
+ const request = ++viewRequest.current;
741
+ try {
742
+ const context2 = await thread.context(commentId);
743
+ if (request !== viewRequest.current) {
744
+ return;
745
+ }
746
+ setFocusView({
747
+ id: commentId,
748
+ ancestors: context2.ancestors,
749
+ truncated: context2.ancestorsTruncated
750
+ });
751
+ focusComment(commentId);
752
+ } catch (error) {
753
+ if (request === viewRequest.current) {
754
+ reportError("context", error);
755
+ }
756
+ }
757
+ },
758
+ [thread, focusComment, reportError]
759
+ );
760
+ const closeFocusView = useCallback(() => {
761
+ viewRequest.current++;
762
+ if (focusView) {
763
+ focusComment(focusView.id);
764
+ }
765
+ setFocusView(null);
766
+ }, [focusView, focusComment]);
767
+ const hashId = useHashCommentId();
768
+ const requestedHighlight = highlight ?? hashId;
769
+ const handledHighlight = useRef(null);
770
+ const ready = snapshot?.status === "ready";
771
+ useEffect(() => {
772
+ const handled = handledHighlight.current;
773
+ if (!thread || !ready || requestedHighlight === null || handled?.thread === thread && handled.id === requestedHighlight) {
774
+ return;
775
+ }
776
+ handledHighlight.current = { thread, id: requestedHighlight };
777
+ const id = requestedHighlight;
778
+ const request = ++viewRequest.current;
779
+ void (async () => {
780
+ let location = findComment(thread.snapshot().comments, id);
781
+ if (!location) {
782
+ try {
783
+ await thread.context(id);
784
+ } catch (error) {
785
+ if (request === viewRequest.current) {
786
+ reportError("context", error);
787
+ }
788
+ return;
789
+ }
790
+ location = findComment(thread.snapshot().comments, id);
791
+ }
792
+ if (!location || request !== viewRequest.current) {
793
+ return;
794
+ }
795
+ if (location.depth > maxDepth) {
796
+ const cut = location.depth - maxDepth;
797
+ const tree = thread.snapshot().comments;
798
+ const ancestors = location.path.slice(0, cut).map((ancestorId) => findComment(tree, ancestorId)?.node).filter((node) => node !== void 0);
799
+ setFocusView({ id: location.path[cut], ancestors, truncated: false });
800
+ } else {
801
+ setFocusView(null);
802
+ }
803
+ setHighlightId(id);
804
+ focusComment(id, { scroll: true });
805
+ })();
806
+ }, [thread, ready, requestedHighlight, maxDepth, focusComment, reportError]);
807
+ const onSignInRequiredRef = useRef(onSignInRequired);
808
+ onSignInRequiredRef.current = onSignInRequired;
809
+ const canRequestSignIn = signInMode !== "manual" || onSignInRequired !== void 0;
810
+ const ensureSignedIn = useCallback(() => {
811
+ if (client.auth.status === "signed-in") {
812
+ return Promise.resolve(true);
813
+ }
814
+ if (signInMode === "manual") {
815
+ onSignInRequiredRef.current?.();
816
+ return Promise.resolve(false);
817
+ }
818
+ return client.auth.signIn().then(
819
+ (user) => user !== null,
820
+ () => false
821
+ );
822
+ }, [client, signInMode]);
823
+ const threadKey = snapshot?.thread.key;
824
+ const draftKey = useCallback(
825
+ (scope) => threadKey === void 0 ? null : `${STORAGE_PREFIX}:${client.forum}:draft:${threadKey}:${scope}`,
826
+ [client.forum, threadKey]
827
+ );
828
+ const value = {
829
+ client,
830
+ t,
831
+ locale,
832
+ thread,
833
+ snapshot,
834
+ openError,
835
+ retry,
836
+ config,
837
+ configError,
838
+ commenter,
839
+ maxDepth,
840
+ signInMode,
841
+ readOnly,
842
+ guidelinesUrl: guidelinesUrl ?? config?.guidelines_url ?? null,
843
+ debug: context.debug,
844
+ elements,
845
+ writable,
846
+ canCompose,
847
+ commentingDenied,
848
+ canReply,
849
+ canRequestSignIn,
850
+ focusView,
851
+ continueThread,
852
+ closeFocusView,
853
+ highlightId,
854
+ focusComment,
855
+ ensureSignedIn,
856
+ reportError,
857
+ emit,
858
+ onPosted,
859
+ draftKey
860
+ };
861
+ return /* @__PURE__ */ jsx(RootContext.Provider, { value, children: /* @__PURE__ */ jsx(
862
+ "section",
863
+ {
864
+ ref: rootRef,
865
+ className: cx("gc-root", !unstyled && "gc-root--styled", elements.root, className),
866
+ "data-gc-theme": theme === "inherit" ? void 0 : theme,
867
+ style: variablesStyle(variables),
868
+ "aria-label": t("comments"),
869
+ "aria-busy": !snapshot && !openError ? true : void 0,
870
+ children
871
+ }
872
+ ) });
873
+ }
874
+ function useHashCommentId() {
875
+ const [id, setId] = useState(null);
876
+ useEffect(() => {
877
+ const read = () => {
878
+ const match = /^#gc-(\d+)$/.exec(window.location.hash);
879
+ setId(match ? Number(match[1]) : null);
880
+ };
881
+ read();
882
+ window.addEventListener("hashchange", read);
883
+ return () => window.removeEventListener("hashchange", read);
884
+ }, []);
885
+ return id;
886
+ }
887
+
888
+ // src/react/composer/markdown.ts
889
+ function applyFormat(value, start, end, format) {
890
+ const selected = value.slice(start, end);
891
+ switch (format) {
892
+ case "bold":
893
+ return wrap(value, start, end, "**", "**");
894
+ case "italic":
895
+ return wrap(value, start, end, "*", "*");
896
+ case "code":
897
+ return selected.includes("\n") ? fence(value, start, end) : wrap(value, start, end, "`", "`");
898
+ case "link": {
899
+ const url = "https://";
900
+ const urlStart = start + selected.length + 3;
901
+ return {
902
+ value: `${value.slice(0, start)}[${selected}](${url})${value.slice(end)}`,
903
+ selectionStart: urlStart,
904
+ selectionEnd: urlStart + url.length
905
+ };
906
+ }
907
+ case "quote":
908
+ return quote(value, start, end);
909
+ }
910
+ }
911
+ function insertText(value, start, end, text) {
912
+ const caret = start + text.length;
913
+ return {
914
+ value: value.slice(0, start) + text + value.slice(end),
915
+ selectionStart: caret,
916
+ selectionEnd: caret
917
+ };
918
+ }
919
+ function imageMarkdown(upload) {
920
+ const name = upload.original_filename.replace(/\.[^.]+$/, "").replace(/[[\]|]/g, "");
921
+ const size = upload.width && upload.height ? `|${upload.width}x${upload.height}` : "";
922
+ return `![${name}${size}](${upload.short_url})`;
923
+ }
924
+ function mentionQuery(value, caret) {
925
+ const match = /(?:^|[\s([{])@([\w.-]{1,60})$/.exec(value.slice(0, caret));
926
+ if (!match) {
927
+ return null;
928
+ }
929
+ const query = match[1];
930
+ return { start: caret - query.length - 1, query };
931
+ }
932
+ function wrap(value, start, end, before, after) {
933
+ return {
934
+ value: value.slice(0, start) + before + value.slice(start, end) + after + value.slice(end),
935
+ selectionStart: start + before.length,
936
+ selectionEnd: end + before.length
937
+ };
938
+ }
939
+ function fence(value, start, end) {
940
+ const before = start > 0 && value[start - 1] !== "\n" ? "\n" : "";
941
+ const open = `${before}\`\`\`
942
+ `;
943
+ const close = "\n```\n";
944
+ return {
945
+ value: value.slice(0, start) + open + value.slice(start, end) + close + value.slice(end),
946
+ selectionStart: start + open.length,
947
+ selectionEnd: end + open.length
948
+ };
949
+ }
950
+ function quote(value, start, end) {
951
+ const lineStart = value.lastIndexOf("\n", start - 1) + 1;
952
+ const lines = value.slice(lineStart, end).split("\n");
953
+ const quoted = lines.map((line) => `> ${line}`).join("\n");
954
+ return {
955
+ value: value.slice(0, lineStart) + quoted + value.slice(end),
956
+ selectionStart: start + 2,
957
+ selectionEnd: end + 2 * lines.length
958
+ };
959
+ }
960
+ var SEARCH_DELAY_MS = 150;
961
+ function useMentions(options) {
962
+ const { client, threadId, enabled, value, caret, onPick } = options;
963
+ const query = enabled ? mentionQuery(value, caret) : null;
964
+ const [results, setResults] = useState({
965
+ query: "",
966
+ users: []
967
+ });
968
+ const [active, setActive] = useState(0);
969
+ const [dismissed, setDismissed] = useState(null);
970
+ const queryKey = query ? `${query.start}:${query.query}` : null;
971
+ useEffect(() => {
972
+ if (!query) {
973
+ return;
974
+ }
975
+ let current = true;
976
+ const timer = setTimeout(() => {
977
+ client.mentions(query.query, threadId).then(
978
+ (users) => {
979
+ if (current) {
980
+ setResults({ query: query.query, users });
981
+ setActive(0);
982
+ }
983
+ },
984
+ () => {
985
+ }
986
+ );
987
+ }, SEARCH_DELAY_MS);
988
+ return () => {
989
+ current = false;
990
+ clearTimeout(timer);
991
+ };
992
+ }, [client, threadId, query?.query]);
993
+ const open = query !== null && results.query === query.query && results.users.length > 0 && dismissed !== queryKey;
994
+ const pick = useCallback(
995
+ (index) => {
996
+ const user = results.users[index];
997
+ if (query && user) {
998
+ onPick(query, user.username);
999
+ }
1000
+ },
1001
+ [results.users, query, onPick]
1002
+ );
1003
+ const onKeyDown = (event) => {
1004
+ if (!open) {
1005
+ return false;
1006
+ }
1007
+ const count = results.users.length;
1008
+ switch (event.key) {
1009
+ case "ArrowDown":
1010
+ setActive((index) => (index + 1) % count);
1011
+ break;
1012
+ case "ArrowUp":
1013
+ setActive((index) => (index - 1 + count) % count);
1014
+ break;
1015
+ case "Enter":
1016
+ case "Tab":
1017
+ if (event.metaKey || event.ctrlKey) {
1018
+ return false;
1019
+ }
1020
+ pick(active);
1021
+ break;
1022
+ case "Escape":
1023
+ setDismissed(queryKey);
1024
+ break;
1025
+ default:
1026
+ return false;
1027
+ }
1028
+ event.preventDefault();
1029
+ event.stopPropagation();
1030
+ return true;
1031
+ };
1032
+ return { open, users: results.users, active, setActive, onKeyDown, pick };
1033
+ }
1034
+ function Composer(props) {
1035
+ const root = useRootContext("Comments.Composer");
1036
+ if (!root.thread || !root.snapshot || !composerAllowed(root, props.editing)) {
1037
+ return null;
1038
+ }
1039
+ return /* @__PURE__ */ jsx(ComposerForm, { ...props, thread: root.thread });
1040
+ }
1041
+ function composerAllowed(root, editing) {
1042
+ if (editing) {
1043
+ return root.writable && root.commenter.status === "signed-in" && editing.can.edit;
1044
+ }
1045
+ return root.canCompose;
1046
+ }
1047
+ var FORMATS = [
1048
+ { format: "bold", label: "bold", icon: /* @__PURE__ */ jsx(BoldIcon, {}) },
1049
+ { format: "italic", label: "italic", icon: /* @__PURE__ */ jsx(ItalicIcon, {}) },
1050
+ { format: "link", label: "insertLink", icon: /* @__PURE__ */ jsx(LinkIcon, {}) },
1051
+ { format: "code", label: "code", icon: /* @__PURE__ */ jsx(CodeIcon, {}) },
1052
+ { format: "quote", label: "quote", icon: /* @__PURE__ */ jsx(QuoteIcon, {}) }
1053
+ ];
1054
+ function ComposerForm({
1055
+ replyTo,
1056
+ editing,
1057
+ placeholder,
1058
+ autoFocus,
1059
+ onDone,
1060
+ className,
1061
+ thread
1062
+ }) {
1063
+ const root = useRootContext();
1064
+ const { t, client, config, commenter, elements } = root;
1065
+ const id = useId();
1066
+ const textareaRef = useRef(null);
1067
+ const fileRef = useRef(null);
1068
+ const pendingSelection = useRef(null);
1069
+ const [timer] = useState(createComposerTimer);
1070
+ const draftKey = editing ? null : root.draftKey(replyTo ? `reply:${replyTo.id}` : "root");
1071
+ const [value, setValueState] = useState(() => draftKey ? readDraft(draftKey) : "");
1072
+ const valueRef = useRef(value);
1073
+ valueRef.current = value;
1074
+ const [caret, setCaret] = useState(0);
1075
+ const [raw, setRaw] = useState(editing ? "loading" : "loaded");
1076
+ const [rawAttempt, setRawAttempt] = useState(0);
1077
+ const [tab, setTab] = useState("write");
1078
+ const [preview, setPreview] = useState(
1079
+ { html: null, loading: false, failed: false }
1080
+ );
1081
+ const previewRequest = useRef(0);
1082
+ const [submitting, setSubmitting] = useState(false);
1083
+ const [error, setError] = useState(null);
1084
+ const [uploading, setUploading] = useState(0);
1085
+ const [waitSeconds, startCountdown] = useCountdown();
1086
+ const submission = useRef(0);
1087
+ const allowedRef = useRef(true);
1088
+ allowedRef.current = composerAllowed(root, editing);
1089
+ const setValue = useCallback(
1090
+ (next, selection) => {
1091
+ valueRef.current = next;
1092
+ setValueState(next);
1093
+ if (draftKey) {
1094
+ writeDraft(draftKey, next);
1095
+ }
1096
+ if (selection) {
1097
+ pendingSelection.current = selection;
1098
+ }
1099
+ },
1100
+ [draftKey]
1101
+ );
1102
+ const applyEdit = useCallback(
1103
+ (edit) => setValue(edit.value, [edit.selectionStart, edit.selectionEnd]),
1104
+ [setValue]
1105
+ );
1106
+ useIsomorphicLayoutEffect(() => {
1107
+ const selection = pendingSelection.current;
1108
+ const textarea = textareaRef.current;
1109
+ if (selection && textarea) {
1110
+ pendingSelection.current = null;
1111
+ textarea.focus();
1112
+ textarea.setSelectionRange(selection[0], selection[1]);
1113
+ setCaret(selection[1]);
1114
+ }
1115
+ });
1116
+ useEffect(() => {
1117
+ const textarea = textareaRef.current;
1118
+ if (autoFocus && !editing && textarea) {
1119
+ const end = valueRef.current.length;
1120
+ textarea.focus();
1121
+ textarea.setSelectionRange(end, end);
1122
+ setCaret(end);
1123
+ }
1124
+ }, [autoFocus, editing]);
1125
+ useEffect(
1126
+ () => () => {
1127
+ submission.current++;
1128
+ },
1129
+ [thread]
1130
+ );
1131
+ const editingId = editing?.id;
1132
+ useEffect(() => {
1133
+ if (editingId === void 0) {
1134
+ return;
1135
+ }
1136
+ let active = true;
1137
+ setRaw("loading");
1138
+ setError(null);
1139
+ thread.raw(editingId).then(
1140
+ (source) => {
1141
+ if (active) {
1142
+ setValue(source, [source.length, source.length]);
1143
+ setRaw("loaded");
1144
+ }
1145
+ },
1146
+ (failure) => {
1147
+ if (active) {
1148
+ setError(toCommentsError(failure));
1149
+ setRaw("failed");
1150
+ }
1151
+ }
1152
+ );
1153
+ return () => {
1154
+ active = false;
1155
+ };
1156
+ }, [thread, editingId, setValue, rawAttempt]);
1157
+ useEffect(() => {
1158
+ if (waitSeconds === 0) {
1159
+ setError((current) => current?.type === "rate_limit" ? null : current);
1160
+ }
1161
+ }, [waitSeconds]);
1162
+ const signedIn = commenter.status === "signed-in";
1163
+ const signingIn = commenter.status === "signing-in";
1164
+ const uploadsEnabled = signedIn && config?.features.uploads === true;
1165
+ const min = Math.max(config?.limits.min_post_length ?? 1, 1);
1166
+ const max = config?.limits.max_post_length ?? Number.POSITIVE_INFINITY;
1167
+ const length = value.trim().length;
1168
+ const tooShort = length > 0 && length < min;
1169
+ const tooLong = length > max;
1170
+ const loadingRaw = raw === "loading";
1171
+ const editable = raw === "loaded" && !submitting;
1172
+ const busy = submitting || raw !== "loaded" || uploading > 0 || waitSeconds > 0 || signingIn;
1173
+ const canSubmit = !busy && length >= min && !tooLong;
1174
+ const onPickMention = useCallback(
1175
+ (query, username) => applyEdit(insertText(valueRef.current, query.start, caret, `@${username} `)),
1176
+ [applyEdit, caret]
1177
+ );
1178
+ const mentions = useMentions({
1179
+ client,
1180
+ threadId: thread.id,
1181
+ enabled: signedIn && config?.features.mentions !== false && tab === "write" && editable,
1182
+ value,
1183
+ caret,
1184
+ onPick: onPickMention
1185
+ });
1186
+ async function send() {
1187
+ if (!allowedRef.current) {
1188
+ return;
1189
+ }
1190
+ const body = valueRef.current;
1191
+ setSubmitting(true);
1192
+ setError(null);
1193
+ try {
1194
+ if (editing) {
1195
+ await thread.edit(editing.id, body);
1196
+ onDone?.();
1197
+ return;
1198
+ }
1199
+ const result = await thread.post({ body, replyTo: replyTo?.id, composer: timer });
1200
+ if (valueRef.current === body) {
1201
+ setValue("");
1202
+ }
1203
+ setTab("write");
1204
+ if (result.type === "created") {
1205
+ root.emit({ type: "comment.posted", threadId: thread.id, comment: result.comment });
1206
+ if (replyTo) {
1207
+ root.focusComment(result.comment.id);
1208
+ }
1209
+ } else {
1210
+ root.emit({ type: "comment.pending", threadId: thread.id, pending: result.pending });
1211
+ }
1212
+ root.onPosted?.(result);
1213
+ onDone?.();
1214
+ } catch (failure) {
1215
+ const commentsError = toCommentsError(failure);
1216
+ if (commentsError.type === "rate_limit" && commentsError.waitSeconds) {
1217
+ startCountdown(commentsError.waitSeconds);
1218
+ }
1219
+ setError(commentsError);
1220
+ root.reportError(editing ? "edit" : "post", commentsError);
1221
+ } finally {
1222
+ setSubmitting(false);
1223
+ }
1224
+ }
1225
+ function submit() {
1226
+ if (!canSubmit) {
1227
+ return;
1228
+ }
1229
+ if (signedIn) {
1230
+ void send();
1231
+ return;
1232
+ }
1233
+ if (root.signInMode === "on-submit") {
1234
+ const token = ++submission.current;
1235
+ setError(null);
1236
+ client.auth.signIn().then(
1237
+ (user) => {
1238
+ if (user && token === submission.current) {
1239
+ void send();
1240
+ }
1241
+ },
1242
+ (failure) => {
1243
+ const signInError = toCommentsError(failure);
1244
+ if (token === submission.current && signInError.type !== "cancelled") {
1245
+ setError(signInError);
1246
+ }
1247
+ }
1248
+ );
1249
+ }
1250
+ }
1251
+ function cancel() {
1252
+ submission.current++;
1253
+ onDone?.();
1254
+ }
1255
+ function format(kind) {
1256
+ const textarea = textareaRef.current;
1257
+ const start = textarea?.selectionStart ?? valueRef.current.length;
1258
+ const end = textarea?.selectionEnd ?? start;
1259
+ applyEdit(applyFormat(valueRef.current, start, end, kind));
1260
+ }
1261
+ async function upload(files, pasted = false) {
1262
+ for (const file of files.filter((candidate) => candidate.type.startsWith("image/"))) {
1263
+ setUploading((count) => count + 1);
1264
+ try {
1265
+ const uploaded = await client.attachments.upload(thread.id, file, { pasted });
1266
+ const textarea = textareaRef.current;
1267
+ const current = valueRef.current;
1268
+ const start = textarea?.selectionStart ?? current.length;
1269
+ const end = textarea?.selectionEnd ?? start;
1270
+ const before = start > 0 && current[start - 1] !== "\n" ? "\n" : "";
1271
+ applyEdit(insertText(current, start, end, `${before}${imageMarkdown(uploaded)}
1272
+ `));
1273
+ } catch (failure) {
1274
+ setError(toCommentsError(failure));
1275
+ root.reportError("upload", failure);
1276
+ } finally {
1277
+ setUploading((count) => count - 1);
1278
+ }
1279
+ }
1280
+ }
1281
+ function showPreview() {
1282
+ setTab("preview");
1283
+ const body = valueRef.current;
1284
+ const request = ++previewRequest.current;
1285
+ if (!body.trim()) {
1286
+ setPreview({ html: null, loading: false, failed: false });
1287
+ return;
1288
+ }
1289
+ setPreview({ html: null, loading: true, failed: false });
1290
+ client.preview(body).then(
1291
+ (html) => {
1292
+ if (request === previewRequest.current) {
1293
+ setPreview({ html, loading: false, failed: false });
1294
+ }
1295
+ },
1296
+ (failure) => {
1297
+ if (request === previewRequest.current) {
1298
+ setPreview({ html: null, loading: false, failed: true });
1299
+ root.reportError("preview", failure);
1300
+ }
1301
+ }
1302
+ );
1303
+ }
1304
+ function onChange(event) {
1305
+ timer.typing();
1306
+ setValue(event.target.value);
1307
+ setCaret(event.target.selectionStart);
1308
+ }
1309
+ function onKeyDown(event) {
1310
+ if (mentions.onKeyDown(event)) {
1311
+ return;
1312
+ }
1313
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
1314
+ event.preventDefault();
1315
+ submit();
1316
+ } else if (event.key === "Escape" && (replyTo || editing)) {
1317
+ event.preventDefault();
1318
+ cancel();
1319
+ }
1320
+ }
1321
+ function onPaste(event) {
1322
+ const files = [...event.clipboardData.files];
1323
+ if (uploadsEnabled && editable && files.some((file) => file.type.startsWith("image/"))) {
1324
+ event.preventDefault();
1325
+ void upload(files, true);
1326
+ }
1327
+ }
1328
+ function onDragOver(event) {
1329
+ if (uploadsEnabled && editable && event.dataTransfer.types.includes("Files")) {
1330
+ event.preventDefault();
1331
+ }
1332
+ }
1333
+ function onDrop(event) {
1334
+ const files = [...event.dataTransfer.files];
1335
+ if (uploadsEnabled && editable && files.length > 0) {
1336
+ event.preventDefault();
1337
+ void upload(files);
1338
+ }
1339
+ }
1340
+ function onTabKeyDown(event) {
1341
+ if (event.key === "ArrowLeft" || event.key === "ArrowRight") {
1342
+ event.preventDefault();
1343
+ if (tab === "write") {
1344
+ showPreview();
1345
+ } else {
1346
+ setTab("write");
1347
+ }
1348
+ rootNodeOf(event.currentTarget).getElementById(`${id}-${tab === "write" ? "preview" : "write"}-tab`)?.focus();
1349
+ }
1350
+ }
1351
+ const label = editing ? t("editing") : replyTo ? t("replyingTo", { username: replyTo.author?.username ?? "" }) : t("composerPlaceholder");
1352
+ const hintId = `${id}-hint`;
1353
+ const errorId = `${id}-error`;
1354
+ const listId = `${id}-mentions`;
1355
+ const messages = error ? errorMessages(error, waitSeconds, t) : [];
1356
+ const submitLabel = editing ? t("save") : replyTo ? t("reply") : t("post");
1357
+ const defaultPlaceholder = editing ? void 0 : replyTo ? t("replyPlaceholder") : t("composerPlaceholder");
1358
+ const hint = tooLong ? t("tooLong", { max }) : tooShort ? t("tooShort", { min }) : null;
1359
+ const canPreview = signedIn;
1360
+ const isMain = !replyTo && !editing;
1361
+ return /* @__PURE__ */ jsxs(
1362
+ "div",
1363
+ {
1364
+ className: cx(
1365
+ "gc-composer",
1366
+ replyTo && "gc-composer--replying",
1367
+ editing && "gc-composer--editing",
1368
+ elements.composer,
1369
+ className
1370
+ ),
1371
+ children: [
1372
+ isMain && root.guidelinesUrl && /* @__PURE__ */ jsx(
1373
+ "a",
1374
+ {
1375
+ className: "gc-composer__guidelines",
1376
+ href: root.guidelinesUrl,
1377
+ target: "_blank",
1378
+ rel: "noopener noreferrer",
1379
+ children: t("guidelines")
1380
+ }
1381
+ ),
1382
+ !isMain && /* @__PURE__ */ jsx("p", { className: "gc-composer__context", children: label }),
1383
+ canPreview && /* @__PURE__ */ jsxs("div", { className: "gc-composer__tabs", role: "tablist", children: [
1384
+ /* @__PURE__ */ jsx(
1385
+ "button",
1386
+ {
1387
+ type: "button",
1388
+ role: "tab",
1389
+ id: `${id}-write-tab`,
1390
+ className: "gc-composer__tab",
1391
+ "aria-selected": tab === "write",
1392
+ "aria-controls": `${id}-write`,
1393
+ tabIndex: tab === "write" ? 0 : -1,
1394
+ onClick: () => setTab("write"),
1395
+ onKeyDown: onTabKeyDown,
1396
+ children: t("write")
1397
+ }
1398
+ ),
1399
+ /* @__PURE__ */ jsx(
1400
+ "button",
1401
+ {
1402
+ type: "button",
1403
+ role: "tab",
1404
+ id: `${id}-preview-tab`,
1405
+ className: "gc-composer__tab",
1406
+ "aria-selected": tab === "preview",
1407
+ "aria-controls": `${id}-preview`,
1408
+ tabIndex: tab === "preview" ? 0 : -1,
1409
+ onClick: showPreview,
1410
+ onKeyDown: onTabKeyDown,
1411
+ children: t("preview")
1412
+ }
1413
+ )
1414
+ ] }),
1415
+ /* @__PURE__ */ jsxs(
1416
+ "div",
1417
+ {
1418
+ className: "gc-composer__panel",
1419
+ id: `${id}-write`,
1420
+ role: canPreview ? "tabpanel" : void 0,
1421
+ "aria-labelledby": canPreview ? `${id}-write-tab` : void 0,
1422
+ hidden: tab !== "write",
1423
+ children: [
1424
+ signedIn && /* @__PURE__ */ jsxs("div", { className: cx("gc-toolbar", elements.toolbar), role: "group", "aria-label": t("formatting"), children: [
1425
+ FORMATS.map(({ format: kind, label: key, icon }) => /* @__PURE__ */ jsx(
1426
+ "button",
1427
+ {
1428
+ type: "button",
1429
+ className: "gc-toolbar__button",
1430
+ "aria-label": t(key),
1431
+ title: t(key),
1432
+ disabled: !editable,
1433
+ onClick: () => format(kind),
1434
+ children: icon
1435
+ },
1436
+ kind
1437
+ )),
1438
+ uploadsEnabled && /* @__PURE__ */ jsxs(Fragment, { children: [
1439
+ /* @__PURE__ */ jsx(
1440
+ "button",
1441
+ {
1442
+ type: "button",
1443
+ className: "gc-toolbar__button",
1444
+ "aria-label": t("upload"),
1445
+ title: t("upload"),
1446
+ disabled: !editable,
1447
+ onClick: () => fileRef.current?.click(),
1448
+ children: /* @__PURE__ */ jsx(ImageIcon, {})
1449
+ }
1450
+ ),
1451
+ /* @__PURE__ */ jsx(
1452
+ "input",
1453
+ {
1454
+ ref: fileRef,
1455
+ type: "file",
1456
+ accept: "image/*",
1457
+ multiple: true,
1458
+ hidden: true,
1459
+ onChange: (event) => {
1460
+ const files = [...event.target.files ?? []];
1461
+ event.target.value = "";
1462
+ void upload(files);
1463
+ }
1464
+ }
1465
+ )
1466
+ ] })
1467
+ ] }),
1468
+ /* @__PURE__ */ jsxs("div", { className: "gc-composer__field", children: [
1469
+ /* @__PURE__ */ jsx(
1470
+ "textarea",
1471
+ {
1472
+ ref: textareaRef,
1473
+ className: cx("gc-composer__input", elements.textarea),
1474
+ value,
1475
+ placeholder: placeholder ?? defaultPlaceholder,
1476
+ "aria-label": label,
1477
+ "aria-describedby": cx(hint && hintId, messages.length > 0 && errorId) || void 0,
1478
+ "aria-invalid": tooLong || void 0,
1479
+ "aria-autocomplete": mentions.open ? "list" : void 0,
1480
+ "aria-controls": mentions.open ? listId : void 0,
1481
+ "aria-activedescendant": mentions.open ? `${listId}-${mentions.active}` : void 0,
1482
+ rows: isMain ? 3 : 2,
1483
+ disabled: raw !== "loaded",
1484
+ readOnly: submitting,
1485
+ onChange,
1486
+ onSelect: (event) => setCaret(event.currentTarget.selectionStart),
1487
+ onFocus: () => timer.start(),
1488
+ onKeyDown,
1489
+ onPaste,
1490
+ onDragOver,
1491
+ onDrop
1492
+ }
1493
+ ),
1494
+ mentions.open && /* @__PURE__ */ jsx("ul", { className: "gc-mentions", role: "listbox", id: listId, "aria-label": t("mentionSuggestions"), children: mentions.users.map((user, index) => /* @__PURE__ */ jsxs(
1495
+ "li",
1496
+ {
1497
+ id: `${listId}-${index}`,
1498
+ role: "option",
1499
+ "aria-selected": index === mentions.active,
1500
+ className: cx(
1501
+ "gc-mentions__option",
1502
+ index === mentions.active && "gc-mentions__option--active"
1503
+ ),
1504
+ onMouseDown: (event) => {
1505
+ event.preventDefault();
1506
+ mentions.pick(index);
1507
+ },
1508
+ onMouseEnter: () => mentions.setActive(index),
1509
+ children: [
1510
+ /* @__PURE__ */ jsx("img", { className: "gc-avatar gc-avatar--small", src: user.avatar_url, alt: "" }),
1511
+ /* @__PURE__ */ jsxs("span", { className: "gc-mentions__username", children: [
1512
+ "@",
1513
+ user.username
1514
+ ] }),
1515
+ user.name && /* @__PURE__ */ jsx("span", { className: "gc-mentions__name", children: user.name })
1516
+ ]
1517
+ },
1518
+ user.id
1519
+ )) })
1520
+ ] })
1521
+ ]
1522
+ }
1523
+ ),
1524
+ canPreview && /* @__PURE__ */ jsx(
1525
+ "div",
1526
+ {
1527
+ className: "gc-composer__panel",
1528
+ id: `${id}-preview`,
1529
+ role: "tabpanel",
1530
+ "aria-labelledby": `${id}-preview-tab`,
1531
+ "aria-busy": preview.loading || void 0,
1532
+ hidden: tab !== "preview",
1533
+ children: preview.loading ? /* @__PURE__ */ jsx("p", { className: "gc-composer__status", children: t("loading") }) : preview.failed ? /* @__PURE__ */ jsx("p", { className: "gc-composer__status", children: t("genericError") }) : preview.html ? /* @__PURE__ */ jsx(
1534
+ "div",
1535
+ {
1536
+ className: "gc-composer__preview gc-prose",
1537
+ dangerouslySetInnerHTML: { __html: preview.html }
1538
+ }
1539
+ ) : /* @__PURE__ */ jsx("p", { className: "gc-composer__status", children: t("previewEmpty") })
1540
+ }
1541
+ ),
1542
+ /* @__PURE__ */ jsxs("div", { className: "gc-composer__footer", children: [
1543
+ /* @__PURE__ */ jsxs("div", { className: "gc-composer__messages", "aria-live": "polite", children: [
1544
+ loadingRaw && /* @__PURE__ */ jsx("p", { className: "gc-composer__status", children: t("loading") }),
1545
+ uploading > 0 && /* @__PURE__ */ jsx("p", { className: "gc-composer__status", children: t("uploading") }),
1546
+ signingIn && /* @__PURE__ */ jsx("p", { className: "gc-composer__status", children: t("signingIn") }),
1547
+ hint && /* @__PURE__ */ jsx("p", { id: hintId, className: "gc-composer__hint", children: hint }),
1548
+ messages.length > 0 && /* @__PURE__ */ jsx("div", { id: errorId, className: "gc-composer__error", role: "alert", children: messages.map((message) => /* @__PURE__ */ jsx("p", { children: message }, message)) })
1549
+ ] }),
1550
+ /* @__PURE__ */ jsxs("div", { className: "gc-composer__buttons", children: [
1551
+ signingIn && /* @__PURE__ */ jsx(
1552
+ "button",
1553
+ {
1554
+ type: "button",
1555
+ className: cx("gc-button", elements.button),
1556
+ onClick: () => client.auth.cancelSignIn(),
1557
+ children: t("signInCancel")
1558
+ }
1559
+ ),
1560
+ raw === "failed" && /* @__PURE__ */ jsx(
1561
+ "button",
1562
+ {
1563
+ type: "button",
1564
+ className: cx("gc-button", elements.button),
1565
+ onClick: () => setRawAttempt((count) => count + 1),
1566
+ children: t("retry")
1567
+ }
1568
+ ),
1569
+ !isMain && !signingIn && /* @__PURE__ */ jsx("button", { type: "button", className: cx("gc-button", elements.button), onClick: cancel, children: t("cancel") }),
1570
+ /* @__PURE__ */ jsx(
1571
+ "button",
1572
+ {
1573
+ type: "button",
1574
+ className: cx("gc-button", "gc-button--primary", elements.button, elements.primaryButton),
1575
+ disabled: !canSubmit,
1576
+ "aria-busy": submitting || void 0,
1577
+ onClick: submit,
1578
+ children: submitting && !editing ? t("posting") : submitLabel
1579
+ }
1580
+ )
1581
+ ] })
1582
+ ] })
1583
+ ]
1584
+ }
1585
+ );
1586
+ }
1587
+ function errorMessages(error, waitSeconds, t) {
1588
+ switch (error.type) {
1589
+ case "rate_limit":
1590
+ return waitSeconds > 0 ? [t("rateLimited", { count: waitSeconds })] : error.errors.slice(0, 1);
1591
+ case "invalid_key":
1592
+ case "not_logged_in":
1593
+ return [t("sessionExpired")];
1594
+ case "unreachable":
1595
+ case "unknown":
1596
+ case "cancelled":
1597
+ return [t("genericError")];
1598
+ default:
1599
+ return error.errors.length > 0 ? error.errors : [t("genericError")];
1600
+ }
1601
+ }
1602
+ function useCountdown() {
1603
+ const [until, setUntil] = useState(0);
1604
+ const [now, setNow] = useState(() => Date.now());
1605
+ useEffect(() => {
1606
+ if (until <= Date.now()) {
1607
+ return;
1608
+ }
1609
+ const timer = setInterval(() => {
1610
+ const at = Date.now();
1611
+ setNow(at);
1612
+ if (at >= until) {
1613
+ clearInterval(timer);
1614
+ }
1615
+ }, 250);
1616
+ return () => clearInterval(timer);
1617
+ }, [until]);
1618
+ const start = useCallback((seconds) => {
1619
+ const at = Date.now();
1620
+ setNow(at);
1621
+ setUntil(at + seconds * 1e3);
1622
+ }, []);
1623
+ return [Math.max(0, Math.ceil((until - now) / 1e3)), start];
1624
+ }
1625
+ var ItemContext = createContext(null);
1626
+ function useItemContext(component) {
1627
+ const value = useContext(ItemContext);
1628
+ if (!value) {
1629
+ throw new Error(`${PACKAGE_NAME}: ${component} must be rendered inside <Comments.Item>`);
1630
+ }
1631
+ return value;
1632
+ }
1633
+ function Avatar({ className }) {
1634
+ const { comment } = useItemContext("Comments.Avatar");
1635
+ const { elements } = useRootContext("Comments.Avatar");
1636
+ return /* @__PURE__ */ jsx(UserAvatar, { user: comment.author, className: cx(elements.avatar, className) });
1637
+ }
1638
+ function UserAvatar({
1639
+ user,
1640
+ className
1641
+ }) {
1642
+ if (user?.avatar_url) {
1643
+ return /* @__PURE__ */ jsx(
1644
+ "img",
1645
+ {
1646
+ className: cx("gc-avatar", className),
1647
+ src: user.avatar_url,
1648
+ alt: "",
1649
+ loading: "lazy",
1650
+ decoding: "async"
1651
+ }
1652
+ );
1653
+ }
1654
+ const initial3 = (user?.name || user?.username || "").charAt(0).toUpperCase();
1655
+ return /* @__PURE__ */ jsx("span", { className: cx("gc-avatar", "gc-avatar--placeholder", className), "aria-hidden": "true", children: initial3 });
1656
+ }
1657
+ function Meta({ className }) {
1658
+ const { comment } = useItemContext("Comments.Meta");
1659
+ const { t, locale, client, elements } = useRootContext("Comments.Meta");
1660
+ const author = comment.author;
1661
+ const name = author ? author.name || author.username : null;
1662
+ return /* @__PURE__ */ jsxs("div", { className: cx("gc-comment__meta", elements.meta, className), children: [
1663
+ name && (author?.username ? /* @__PURE__ */ jsx(
1664
+ "a",
1665
+ {
1666
+ className: "gc-comment__author",
1667
+ href: `${client.forum}/u/${encodeURIComponent(author.username)}`,
1668
+ target: "_blank",
1669
+ rel: "noopener noreferrer",
1670
+ children: name
1671
+ }
1672
+ ) : /* @__PURE__ */ jsx("span", { className: "gc-comment__author", children: name })),
1673
+ author?.staff && /* @__PURE__ */ jsx("span", { className: "gc-badge gc-badge--staff", children: t("staff") }),
1674
+ comment.pinned && /* @__PURE__ */ jsxs("span", { className: "gc-badge gc-badge--featured", children: [
1675
+ /* @__PURE__ */ jsx(PinIcon, {}),
1676
+ t("pinned")
1677
+ ] }),
1678
+ /* @__PURE__ */ jsx("a", { className: "gc-comment__time", href: `#gc-${comment.id}`, children: /* @__PURE__ */ jsx(RelativeTime, { iso: comment.created_at, locale }) }),
1679
+ comment.edited && /* @__PURE__ */ jsx("span", { className: "gc-comment__edited", title: formatAbsolute(comment.updated_at, locale), children: t("edited") })
1680
+ ] });
1681
+ }
1682
+ function RelativeTime({ iso, locale }) {
1683
+ const [now, setNow] = useState(Date.now);
1684
+ useEffect(() => onMinuteTick(() => setNow(Date.now())), []);
1685
+ return /* @__PURE__ */ jsx("time", { dateTime: iso, title: formatAbsolute(iso, locale), children: formatRelative(iso, now, locale) });
1686
+ }
1687
+ function Body({ className }) {
1688
+ const { comment, mode, setMode } = useItemContext("Comments.Body");
1689
+ const { t, elements } = useRootContext("Comments.Body");
1690
+ const classes = cx("gc-comment__body", elements.body, className);
1691
+ if (mode === "edit") {
1692
+ return /* @__PURE__ */ jsx(Composer, { editing: comment, onDone: () => setMode("view") });
1693
+ }
1694
+ if (comment.deleted) {
1695
+ return /* @__PURE__ */ jsx("p", { className: cx(classes, "gc-comment__body--removed"), children: t("deleted") });
1696
+ }
1697
+ if (comment.hidden && !comment.html) {
1698
+ return /* @__PURE__ */ jsx("p", { className: cx(classes, "gc-comment__body--removed"), children: t("hidden") });
1699
+ }
1700
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
1701
+ comment.hidden && /* @__PURE__ */ jsx("p", { className: "gc-comment__hidden-note", children: t("hidden") }),
1702
+ /* @__PURE__ */ jsx("div", { className: cx(classes, "gc-prose"), dangerouslySetInnerHTML: { __html: comment.html } })
1703
+ ] });
1704
+ }
1705
+ function Actions({ className }) {
1706
+ const root = useRootContext("Comments.Actions");
1707
+ const { comment, depth, setMode, setNotice } = useItemContext("Comments.Actions");
1708
+ const { t, thread, commenter, elements, writable } = root;
1709
+ if (!thread || comment.deleted) {
1710
+ return null;
1711
+ }
1712
+ const signedIn = commenter.status === "signed-in";
1713
+ const { likes, can } = comment;
1714
+ const likeDisabled = !writable || (signedIn ? !likes.can_like && !likes.liked : !root.canRequestSignIn);
1715
+ const buttonClass = cx("gc-button", "gc-button--ghost", "gc-button--small", elements.button);
1716
+ const fail = (action, error) => {
1717
+ setNotice({ text: actionError(error, t), error: true });
1718
+ root.reportError(action, error);
1719
+ };
1720
+ const toggleLike = () => {
1721
+ setNotice(null);
1722
+ const liked = !likes.liked;
1723
+ const ready = signedIn ? Promise.resolve(true) : root.ensureSignedIn();
1724
+ void ready.then(async (ok) => {
1725
+ if (!ok) {
1726
+ return;
1727
+ }
1728
+ try {
1729
+ const updated = liked ? await thread.like(comment.id) : await thread.unlike(comment.id);
1730
+ root.emit({ type: "comment.liked", threadId: thread.id, comment: updated, liked });
1731
+ } catch (error) {
1732
+ fail("like", error);
1733
+ }
1734
+ });
1735
+ };
1736
+ const reply = (event) => {
1737
+ const trigger = event.currentTarget;
1738
+ setNotice(null);
1739
+ if (signedIn || root.signInMode === "on-submit") {
1740
+ setMode("reply", trigger);
1741
+ return;
1742
+ }
1743
+ void root.ensureSignedIn().then((ok) => ok && setMode("reply", trigger));
1744
+ };
1745
+ const items = [
1746
+ {
1747
+ key: "copy",
1748
+ label: t("copyLink"),
1749
+ onSelect: () => {
1750
+ const href = `${currentUrlWithoutHash() ?? ""}#gc-${comment.id}`;
1751
+ const clipboard = typeof navigator === "undefined" ? void 0 : navigator.clipboard;
1752
+ if (!clipboard) {
1753
+ window.location.hash = `gc-${comment.id}`;
1754
+ return;
1755
+ }
1756
+ clipboard.writeText(href).then(
1757
+ () => setNotice({ text: t("linkCopied"), error: false }),
1758
+ () => {
1759
+ window.location.hash = `gc-${comment.id}`;
1760
+ }
1761
+ );
1762
+ }
1763
+ }
1764
+ ];
1765
+ if (writable && can.edit) {
1766
+ items.push({ key: "edit", label: t("edit"), onSelect: (trigger) => setMode("edit", trigger) });
1767
+ }
1768
+ if (writable && can.pin && depth === 0) {
1769
+ items.push({
1770
+ key: "pin",
1771
+ label: comment.pinned ? t("unpin") : t("pin"),
1772
+ onSelect: () => {
1773
+ const action = comment.pinned ? thread.unpin(comment.id) : thread.pin(comment.id);
1774
+ action.catch((error) => fail("pin", error));
1775
+ }
1776
+ });
1777
+ }
1778
+ if (writable && can.flag) {
1779
+ items.push({ key: "report", label: t("report"), onSelect: (trigger) => setMode("report", trigger) });
1780
+ }
1781
+ if (writable && can.delete) {
1782
+ items.push({
1783
+ key: "delete",
1784
+ label: t("delete"),
1785
+ danger: true,
1786
+ onSelect: (trigger) => setMode("delete", trigger)
1787
+ });
1788
+ }
1789
+ return /* @__PURE__ */ jsxs("div", { className: cx("gc-actions", elements.actions, className), children: [
1790
+ /* @__PURE__ */ jsxs(
1791
+ "button",
1792
+ {
1793
+ type: "button",
1794
+ className: cx(buttonClass, "gc-actions__like", likes.liked && "gc-actions__like--active"),
1795
+ "aria-pressed": likes.liked,
1796
+ "aria-label": `${t(likes.liked ? "unlike" : "like")}, ${t("likeCount", { count: likes.count })}`,
1797
+ disabled: likeDisabled,
1798
+ onClick: toggleLike,
1799
+ children: [
1800
+ /* @__PURE__ */ jsx(HeartIcon, { filled: likes.liked }),
1801
+ /* @__PURE__ */ jsx("span", { className: "gc-actions__count", children: likes.count })
1802
+ ]
1803
+ }
1804
+ ),
1805
+ root.canReply && (signedIn || root.canRequestSignIn) && /* @__PURE__ */ jsxs("button", { type: "button", className: buttonClass, onClick: reply, children: [
1806
+ /* @__PURE__ */ jsx(ReplyIcon, {}),
1807
+ t("reply")
1808
+ ] }),
1809
+ /* @__PURE__ */ jsx(ActionMenu, { items, label: t("moreActions"), buttonClass })
1810
+ ] });
1811
+ }
1812
+ function ActionMenu({
1813
+ items,
1814
+ label,
1815
+ buttonClass
1816
+ }) {
1817
+ const { elements } = useRootContext();
1818
+ const [open, setOpen] = useState(false);
1819
+ const id = useId();
1820
+ const buttonRef = useRef(null);
1821
+ const menuRef = useRef(null);
1822
+ useEffect(() => {
1823
+ if (!open) {
1824
+ return;
1825
+ }
1826
+ menuRef.current?.querySelector('[role="menuitem"]')?.focus();
1827
+ const onPointerDown = (event) => {
1828
+ const path = event.composedPath();
1829
+ const inside = (node) => node !== null && path.includes(node);
1830
+ if (!inside(menuRef.current) && !inside(buttonRef.current)) {
1831
+ setOpen(false);
1832
+ }
1833
+ };
1834
+ document.addEventListener("pointerdown", onPointerDown);
1835
+ return () => document.removeEventListener("pointerdown", onPointerDown);
1836
+ }, [open]);
1837
+ const close = (refocus) => {
1838
+ setOpen(false);
1839
+ {
1840
+ buttonRef.current?.focus();
1841
+ }
1842
+ };
1843
+ const onMenuKeyDown = (event) => {
1844
+ const options = [...menuRef.current?.querySelectorAll('[role="menuitem"]') ?? []];
1845
+ const index = options.indexOf(rootNodeOf(event.currentTarget).activeElement);
1846
+ const move = (next) => options[(next + options.length) % options.length]?.focus();
1847
+ switch (event.key) {
1848
+ case "ArrowDown":
1849
+ move(index + 1);
1850
+ break;
1851
+ case "ArrowUp":
1852
+ move(index - 1);
1853
+ break;
1854
+ case "Home":
1855
+ move(0);
1856
+ break;
1857
+ case "End":
1858
+ move(options.length - 1);
1859
+ break;
1860
+ case "Escape":
1861
+ close();
1862
+ break;
1863
+ case "Tab":
1864
+ setOpen(false);
1865
+ return;
1866
+ default:
1867
+ return;
1868
+ }
1869
+ event.preventDefault();
1870
+ event.stopPropagation();
1871
+ };
1872
+ return /* @__PURE__ */ jsxs("div", { className: "gc-menu", children: [
1873
+ /* @__PURE__ */ jsx(
1874
+ "button",
1875
+ {
1876
+ ref: buttonRef,
1877
+ type: "button",
1878
+ className: cx(buttonClass, "gc-button--icon"),
1879
+ "aria-label": label,
1880
+ "aria-haspopup": "menu",
1881
+ "aria-expanded": open,
1882
+ "aria-controls": open ? id : void 0,
1883
+ onClick: () => setOpen((current) => !current),
1884
+ children: /* @__PURE__ */ jsx(MoreIcon, {})
1885
+ }
1886
+ ),
1887
+ open && /* @__PURE__ */ jsx(
1888
+ "ul",
1889
+ {
1890
+ ref: menuRef,
1891
+ id,
1892
+ role: "menu",
1893
+ "aria-label": label,
1894
+ className: cx("gc-menu__list", elements.menu),
1895
+ onKeyDown: onMenuKeyDown,
1896
+ children: items.map((item) => /* @__PURE__ */ jsx("li", { role: "none", children: /* @__PURE__ */ jsx(
1897
+ "button",
1898
+ {
1899
+ type: "button",
1900
+ role: "menuitem",
1901
+ tabIndex: -1,
1902
+ className: cx("gc-menu__item", item.danger && "gc-menu__item--danger"),
1903
+ onClick: () => {
1904
+ close();
1905
+ item.onSelect(buttonRef.current);
1906
+ },
1907
+ children: item.label
1908
+ }
1909
+ ) }, item.key))
1910
+ }
1911
+ )
1912
+ ] });
1913
+ }
1914
+ var FLAG_TYPES = [
1915
+ "inappropriate",
1916
+ "spam",
1917
+ "illegal",
1918
+ "off_topic",
1919
+ "notify_moderators"
1920
+ ];
1921
+ var MESSAGE_REQUIRED = /* @__PURE__ */ new Set(["illegal", "notify_moderators"]);
1922
+ function ReportForm({ onClose }) {
1923
+ const root = useRootContext("Comments.Item");
1924
+ const { comment, setNotice } = useItemContext("Comments.Item");
1925
+ const { t, thread, elements } = root;
1926
+ const [type, setType] = useState("inappropriate");
1927
+ const [message, setMessage] = useState("");
1928
+ const [submitting, setSubmitting] = useState(false);
1929
+ const [error, setError] = useState(null);
1930
+ const id = useId();
1931
+ const formRef = useRef(null);
1932
+ const needsMessage = MESSAGE_REQUIRED.has(type);
1933
+ const messageLabel = needsMessage ? t("reportMessageRequired") : t("reportMessagePlaceholder");
1934
+ useEffect(() => {
1935
+ formRef.current?.querySelector("input:checked")?.focus();
1936
+ }, []);
1937
+ const submit = async (event) => {
1938
+ event.preventDefault();
1939
+ if (!thread || !root.writable || !comment.can.flag || needsMessage && !message.trim()) {
1940
+ return;
1941
+ }
1942
+ setSubmitting(true);
1943
+ setError(null);
1944
+ try {
1945
+ await thread.flag(comment.id, { type, message: message.trim() || void 0 });
1946
+ setNotice({ text: t("reportSent"), error: false });
1947
+ onClose();
1948
+ } catch (failure) {
1949
+ setError(actionError(failure, t));
1950
+ root.reportError("flag", failure);
1951
+ setSubmitting(false);
1952
+ }
1953
+ };
1954
+ return /* @__PURE__ */ jsx(
1955
+ "div",
1956
+ {
1957
+ role: "dialog",
1958
+ "aria-modal": "false",
1959
+ "aria-labelledby": `${id}-title`,
1960
+ className: cx("gc-report", elements.dialog),
1961
+ onKeyDown: (event) => {
1962
+ if (event.key === "Escape") {
1963
+ event.stopPropagation();
1964
+ onClose();
1965
+ }
1966
+ },
1967
+ children: /* @__PURE__ */ jsxs("form", { ref: formRef, onSubmit: submit, children: [
1968
+ /* @__PURE__ */ jsxs("fieldset", { className: "gc-report__options", children: [
1969
+ /* @__PURE__ */ jsx("legend", { id: `${id}-title`, className: "gc-report__title", children: t("reportTitle") }),
1970
+ FLAG_TYPES.map((flag) => /* @__PURE__ */ jsxs("label", { className: "gc-report__option", children: [
1971
+ /* @__PURE__ */ jsx(
1972
+ "input",
1973
+ {
1974
+ type: "radio",
1975
+ name: `${id}-type`,
1976
+ value: flag,
1977
+ checked: type === flag,
1978
+ onChange: () => setType(flag)
1979
+ }
1980
+ ),
1981
+ t(`report.${flag}`)
1982
+ ] }, flag))
1983
+ ] }),
1984
+ /* @__PURE__ */ jsx(
1985
+ "textarea",
1986
+ {
1987
+ className: "gc-report__message",
1988
+ value: message,
1989
+ placeholder: messageLabel,
1990
+ "aria-label": messageLabel,
1991
+ required: needsMessage,
1992
+ rows: 2,
1993
+ onChange: (event) => setMessage(event.target.value)
1994
+ }
1995
+ ),
1996
+ error && /* @__PURE__ */ jsx("p", { className: "gc-report__error", role: "alert", children: error }),
1997
+ /* @__PURE__ */ jsxs("div", { className: "gc-report__buttons", children: [
1998
+ /* @__PURE__ */ jsx("button", { type: "button", className: cx("gc-button", elements.button), onClick: onClose, children: t("cancel") }),
1999
+ /* @__PURE__ */ jsx(
2000
+ "button",
2001
+ {
2002
+ type: "submit",
2003
+ className: cx("gc-button", "gc-button--primary", elements.button, elements.primaryButton),
2004
+ disabled: submitting || needsMessage && !message.trim(),
2005
+ children: t("report")
2006
+ }
2007
+ )
2008
+ ] })
2009
+ ] })
2010
+ }
2011
+ );
2012
+ }
2013
+ function DeleteConfirm({ onClose }) {
2014
+ const root = useRootContext("Comments.Item");
2015
+ const { comment, setNotice } = useItemContext("Comments.Item");
2016
+ const { t, thread, elements } = root;
2017
+ const [deleting, setDeleting] = useState(false);
2018
+ const id = useId();
2019
+ const cancelRef = useRef(null);
2020
+ useEffect(() => cancelRef.current?.focus(), []);
2021
+ const confirm = async () => {
2022
+ if (!thread || !root.writable || !comment.can.delete) {
2023
+ return;
2024
+ }
2025
+ setDeleting(true);
2026
+ try {
2027
+ await thread.remove(comment.id);
2028
+ } catch (failure) {
2029
+ setNotice({ text: actionError(failure, t), error: true });
2030
+ root.reportError("delete", failure);
2031
+ }
2032
+ onClose();
2033
+ };
2034
+ return /* @__PURE__ */ jsxs(
2035
+ "div",
2036
+ {
2037
+ role: "alertdialog",
2038
+ "aria-modal": "false",
2039
+ "aria-labelledby": id,
2040
+ className: cx("gc-confirm", elements.dialog),
2041
+ onKeyDown: (event) => {
2042
+ if (event.key === "Escape") {
2043
+ event.stopPropagation();
2044
+ onClose();
2045
+ }
2046
+ },
2047
+ children: [
2048
+ /* @__PURE__ */ jsx("p", { id, className: "gc-confirm__text", children: t("deleteConfirm") }),
2049
+ /* @__PURE__ */ jsxs("div", { className: "gc-confirm__buttons", children: [
2050
+ /* @__PURE__ */ jsx("button", { ref: cancelRef, type: "button", className: cx("gc-button", elements.button), onClick: onClose, children: t("cancel") }),
2051
+ /* @__PURE__ */ jsx(
2052
+ "button",
2053
+ {
2054
+ type: "button",
2055
+ className: cx("gc-button", "gc-button--danger", elements.button),
2056
+ disabled: deleting,
2057
+ onClick: () => void confirm(),
2058
+ children: t("delete")
2059
+ }
2060
+ )
2061
+ ] })
2062
+ ]
2063
+ }
2064
+ );
2065
+ }
2066
+ function actionError(error, t) {
2067
+ const failure = toCommentsError(error);
2068
+ if (failure.type === "rate_limit" && failure.waitSeconds) {
2069
+ return t("rateLimited", { count: failure.waitSeconds });
2070
+ }
2071
+ if (failure.type === "invalid_key" || failure.type === "not_logged_in") {
2072
+ return t("sessionExpired");
2073
+ }
2074
+ if (failure.type === "unreachable" || failure.type === "unknown" || failure.errors.length === 0) {
2075
+ return t("genericError");
2076
+ }
2077
+ return failure.errors[0];
2078
+ }
2079
+ function DevPanel({ className }) {
2080
+ const { client, openError, snapshot, configError, debug, commenter } = useRootContext("Comments.DevPanel");
2081
+ if (!debug && !isLocalPage()) {
2082
+ return null;
2083
+ }
2084
+ const error = openError ?? (snapshot?.status === "error" ? snapshot.error : null) ?? configError ?? null;
2085
+ const origin = typeof window === "undefined" ? "" : window.location.origin;
2086
+ if (!error) {
2087
+ if (!debug || !snapshot) {
2088
+ return null;
2089
+ }
2090
+ return /* @__PURE__ */ jsxs("details", { className: cx("gc-devpanel", "gc-devpanel--ok", className), children: [
2091
+ /* @__PURE__ */ jsxs("summary", { className: "gc-devpanel__title", children: [
2092
+ PACKAGE_NAME,
2093
+ " diagnostics"
2094
+ ] }),
2095
+ /* @__PURE__ */ jsxs("dl", { className: "gc-devpanel__details", children: [
2096
+ /* @__PURE__ */ jsx("dt", { children: "Forum" }),
2097
+ /* @__PURE__ */ jsx("dd", { children: client.forum }),
2098
+ /* @__PURE__ */ jsx("dt", { children: "Thread" }),
2099
+ /* @__PURE__ */ jsxs("dd", { children: [
2100
+ snapshot.thread.id,
2101
+ " ",
2102
+ snapshot.virtual ? "(not created yet)" : ""
2103
+ ] }),
2104
+ /* @__PURE__ */ jsx("dt", { children: "Key" }),
2105
+ /* @__PURE__ */ jsx("dd", { children: snapshot.thread.key }),
2106
+ /* @__PURE__ */ jsx("dt", { children: "Updates" }),
2107
+ /* @__PURE__ */ jsx("dd", { children: snapshot.connection }),
2108
+ /* @__PURE__ */ jsx("dt", { children: "Reader" }),
2109
+ /* @__PURE__ */ jsxs("dd", { children: [
2110
+ commenter.status,
2111
+ commenter.degraded ? " (degraded)" : ""
2112
+ ] })
2113
+ ] })
2114
+ ] });
2115
+ }
2116
+ const { summary, causes } = diagnose(error, client.forum, origin);
2117
+ return /* @__PURE__ */ jsxs(
2118
+ "aside",
2119
+ {
2120
+ className: cx("gc-devpanel", className),
2121
+ "aria-label": `${PACKAGE_NAME} setup diagnostics`,
2122
+ children: [
2123
+ /* @__PURE__ */ jsxs("p", { className: "gc-devpanel__title", children: [
2124
+ PACKAGE_NAME,
2125
+ " could not load comments"
2126
+ ] }),
2127
+ /* @__PURE__ */ jsx("p", { className: "gc-devpanel__summary", children: summary }),
2128
+ causes.length > 0 && /* @__PURE__ */ jsx("ol", { className: "gc-devpanel__causes", children: causes.map((cause) => /* @__PURE__ */ jsxs("li", { children: [
2129
+ cause.text,
2130
+ cause.link && /* @__PURE__ */ jsxs(Fragment, { children: [
2131
+ " ",
2132
+ /* @__PURE__ */ jsx("a", { href: cause.link.href, target: "_blank", rel: "noopener noreferrer", children: cause.link.label })
2133
+ ] }),
2134
+ cause.code && /* @__PURE__ */ jsx("pre", { className: "gc-devpanel__code", children: /* @__PURE__ */ jsx("code", { children: cause.code }) })
2135
+ ] }, cause.text)) }),
2136
+ /* @__PURE__ */ jsxs("dl", { className: "gc-devpanel__details", children: [
2137
+ /* @__PURE__ */ jsx("dt", { children: "Forum" }),
2138
+ /* @__PURE__ */ jsx("dd", { children: client.forum }),
2139
+ /* @__PURE__ */ jsx("dt", { children: "Page origin" }),
2140
+ /* @__PURE__ */ jsx("dd", { children: origin }),
2141
+ /* @__PURE__ */ jsx("dt", { children: "Error" }),
2142
+ /* @__PURE__ */ jsxs("dd", { children: [
2143
+ /* @__PURE__ */ jsx("code", { children: error.type }),
2144
+ " (status ",
2145
+ error.status,
2146
+ "): ",
2147
+ error.message
2148
+ ] })
2149
+ ] }),
2150
+ /* @__PURE__ */ jsx("p", { className: "gc-devpanel__note", children: "Shown on localhost or with debug. Readers see a short \u201CComments are unavailable\u201D notice." })
2151
+ ]
2152
+ }
2153
+ );
2154
+ }
2155
+ function diagnose(error, forum, origin) {
2156
+ const host = origin ? new URL(origin).host : "this site's host";
2157
+ const embedding = {
2158
+ href: `${forum}/admin/customize/embedding`,
2159
+ label: "Open Admin \u2192 Customize \u2192 Embedding"
2160
+ };
2161
+ const setting = (name) => ({
2162
+ href: `${forum}/admin/site_settings/category/all_results?filter=${name}`,
2163
+ label: `Open ${name}`
2164
+ });
2165
+ const originNotAllowed = {
2166
+ text: `This page's origin (${origin || "unknown"}) is not allowed: add ${host} as an embedding host, or add the origin to discourse_comments_allowed_origins.`,
2167
+ link: embedding
2168
+ };
2169
+ const pluginDisabled = {
2170
+ text: "The discourse-comments plugin is not installed or is disabled (discourse_comments_enabled).",
2171
+ link: setting("discourse_comments_enabled")
2172
+ };
2173
+ switch (error.type) {
2174
+ case "unreachable":
2175
+ return {
2176
+ summary: `The browser got no usable response from ${forum}; CORS hides the reason, so check these in order.`,
2177
+ causes: [
2178
+ originNotAllowed,
2179
+ {
2180
+ text: "The forum is behind HTTP basic auth, which rejects CORS preflight (OPTIONS) requests. Let OPTIONS through without credentials and pass the credentials with the provider's headers prop, e.g. in nginx:",
2181
+ code: [
2182
+ "location / {",
2183
+ " limit_except OPTIONS {",
2184
+ ' auth_basic "Restricted";',
2185
+ " auth_basic_user_file /etc/nginx/.htpasswd;",
2186
+ " }",
2187
+ "}"
2188
+ ].join("\n")
2189
+ },
2190
+ pluginDisabled,
2191
+ {
2192
+ text: "The forum URL is wrong or the forum is offline.",
2193
+ link: { href: forum, label: "Open the forum" }
2194
+ }
2195
+ ]
2196
+ };
2197
+ case "not_found":
2198
+ return { summary: "The comments API answered 404.", causes: [pluginDisabled] };
2199
+ case "page_not_allowed":
2200
+ return {
2201
+ summary: `${forum} does not accept comments for this page.`,
2202
+ causes: [
2203
+ {
2204
+ text: `Add ${host} as an embedding host (check its allowed paths), or add this origin to discourse_comments_allowed_origins.`,
2205
+ link: embedding
2206
+ }
2207
+ ]
2208
+ };
2209
+ case "no_category":
2210
+ return {
2211
+ summary: "The forum has no category for new comment threads.",
2212
+ causes: [
2213
+ { text: `Pick a category for ${host} under Embedding.`, link: embedding },
2214
+ {
2215
+ text: "Or set discourse_comments_default_category (and discourse_comments_default_author).",
2216
+ link: setting("discourse_comments_default_category")
2217
+ }
2218
+ ]
2219
+ };
2220
+ case "invalid_url":
2221
+ return {
2222
+ summary: "The forum rejected the page URL.",
2223
+ causes: [
2224
+ {
2225
+ text: 'Pass an absolute http(s) url prop, or add a <link rel="canonical"> to the page.'
2226
+ }
2227
+ ]
2228
+ };
2229
+ case "thread_conflict":
2230
+ return {
2231
+ summary: "The identifier and the URL already belong to different threads.",
2232
+ causes: [
2233
+ { text: "Pass the identifier this URL was first used with, or only one of the two." }
2234
+ ]
2235
+ };
2236
+ case "invalid_access":
2237
+ return {
2238
+ summary: "The forum refused access to this thread.",
2239
+ causes: [
2240
+ {
2241
+ text: "The thread's category may not be visible to this reader; check the category's security settings."
2242
+ }
2243
+ ]
2244
+ };
2245
+ default:
2246
+ return { summary: error.message, causes: [] };
2247
+ }
2248
+ }
2249
+ function Header({ children, className }) {
2250
+ const { elements } = useRootContext("Comments.Header");
2251
+ return /* @__PURE__ */ jsx("header", { className: cx("gc-header", elements.header, className), children: children ?? /* @__PURE__ */ jsxs(Fragment, { children: [
2252
+ /* @__PURE__ */ jsx(Count, {}),
2253
+ /* @__PURE__ */ jsx(Sort, {})
2254
+ ] }) });
2255
+ }
2256
+ function Count({ as: Tag = "h2", render, className }) {
2257
+ const { t, snapshot, elements } = useRootContext("Comments.Count");
2258
+ const count = snapshot?.thread.count;
2259
+ return /* @__PURE__ */ jsx(Tag, { className: cx("gc-header__count", elements.count, className), children: count === void 0 ? t("comments") : render ? render(count) : t("commentCount", { count }) });
2260
+ }
2261
+ function Sort({ className }) {
2262
+ const { t, thread, snapshot, elements, reportError } = useRootContext("Comments.Sort");
2263
+ const id = useId();
2264
+ if (!thread || !snapshot || snapshot.thread.sorts.length < 2) {
2265
+ return null;
2266
+ }
2267
+ return /* @__PURE__ */ jsxs("div", { className: cx("gc-sort", elements.sort, className), children: [
2268
+ /* @__PURE__ */ jsx("label", { className: "gc-sort__label", htmlFor: id, children: t("sortBy") }),
2269
+ /* @__PURE__ */ jsx(
2270
+ "select",
2271
+ {
2272
+ id,
2273
+ className: "gc-sort__select",
2274
+ value: snapshot.sort,
2275
+ disabled: snapshot.status === "loading",
2276
+ onChange: (event) => void thread.setSort(event.target.value).catch(
2277
+ (error) => reportError("load", error)
2278
+ ),
2279
+ children: snapshot.thread.sorts.map((sort) => /* @__PURE__ */ jsx("option", { value: sort, children: t(`sort.${sort}`) }, sort))
2280
+ }
2281
+ )
2282
+ ] });
2283
+ }
2284
+ var defaultRender = (comment, depth) => /* @__PURE__ */ jsx(Item, { comment, depth });
2285
+ var ListContext = createContext(defaultRender);
2286
+ function List({ children, className }) {
2287
+ const root = useRootContext("Comments.List");
2288
+ const render = children ?? defaultRender;
2289
+ const { snapshot, openError, t, elements, focusView } = root;
2290
+ if (openError || snapshot?.status === "error") {
2291
+ return /* @__PURE__ */ jsxs("div", { className: cx("gc-notice", "gc-notice--error", elements.notice), role: "alert", children: [
2292
+ /* @__PURE__ */ jsx("p", { className: "gc-notice__text", children: t("unavailable") }),
2293
+ /* @__PURE__ */ jsx("button", { type: "button", className: cx("gc-button", elements.button), onClick: root.retry, children: t("retry") })
2294
+ ] });
2295
+ }
2296
+ if (!snapshot || snapshot.status === "loading" && snapshot.comments.length === 0) {
2297
+ return /* @__PURE__ */ jsx("p", { className: "gc-list__status", role: "status", children: t("loading") });
2298
+ }
2299
+ const listClass = cx("gc-list", elements.list, className);
2300
+ if (focusView) {
2301
+ const tree = snapshot.comments;
2302
+ const target = findComment(tree, focusView.id)?.node;
2303
+ return /* @__PURE__ */ jsx(ListContext.Provider, { value: render, children: /* @__PURE__ */ jsxs("div", { className: "gc-focus", children: [
2304
+ /* @__PURE__ */ jsxs(
2305
+ "button",
2306
+ {
2307
+ type: "button",
2308
+ className: cx("gc-button", "gc-button--link", "gc-focus__back", elements.button),
2309
+ onClick: root.closeFocusView,
2310
+ children: [
2311
+ /* @__PURE__ */ jsx(BackIcon, {}),
2312
+ t("backToAll")
2313
+ ]
2314
+ }
2315
+ ),
2316
+ focusView.truncated && /* @__PURE__ */ jsx("p", { className: "gc-focus__truncated", children: t("ancestorsTruncated") }),
2317
+ focusView.ancestors.length > 0 && /* @__PURE__ */ jsx("ol", { className: "gc-context", children: focusView.ancestors.map((ancestor) => /* @__PURE__ */ jsx(
2318
+ Item,
2319
+ {
2320
+ comment: findComment(tree, ancestor.id)?.node ?? ancestor,
2321
+ showReplies: false
2322
+ },
2323
+ ancestor.id
2324
+ )) }),
2325
+ target && /* @__PURE__ */ jsx("ol", { className: listClass, children: render(target, 0) })
2326
+ ] }) });
2327
+ }
2328
+ if (snapshot.comments.length === 0) {
2329
+ return null;
2330
+ }
2331
+ return /* @__PURE__ */ jsx(ListContext.Provider, { value: render, children: /* @__PURE__ */ jsx("ol", { className: listClass, children: snapshot.comments.map((comment) => /* @__PURE__ */ jsx(Fragment$1, { children: render(comment, 0) }, comment.id)) }) });
2332
+ }
2333
+ function Item({ comment, depth = 0, children, showReplies = true, className }) {
2334
+ const root = useRootContext("Comments.Item");
2335
+ const { t, elements } = root;
2336
+ const [mode, setModeState] = useState("view");
2337
+ const [notice, setNotice] = useState(null);
2338
+ const articleRef = useRef(null);
2339
+ const triggerRef = useRef(null);
2340
+ const restoreFocus = useRef(false);
2341
+ const setMode = useCallback((next, trigger) => {
2342
+ if (next === "view") {
2343
+ restoreFocus.current = true;
2344
+ } else {
2345
+ triggerRef.current = trigger ?? null;
2346
+ }
2347
+ setModeState(next);
2348
+ }, []);
2349
+ useEffect(() => {
2350
+ if (mode !== "view" || !restoreFocus.current) {
2351
+ return;
2352
+ }
2353
+ restoreFocus.current = false;
2354
+ const trigger = triggerRef.current;
2355
+ (trigger?.isConnected ? trigger : articleRef.current)?.focus();
2356
+ }, [mode]);
2357
+ const signedIn = root.commenter.status === "signed-in";
2358
+ const writable = root.writable && signedIn;
2359
+ const allowed = {
2360
+ view: true,
2361
+ reply: root.canCompose,
2362
+ edit: writable && comment.can.edit,
2363
+ report: writable && comment.can.flag,
2364
+ delete: writable && comment.can.delete
2365
+ };
2366
+ const modeAllowed = allowed[mode];
2367
+ const activeMode = modeAllowed ? mode : "view";
2368
+ useEffect(() => {
2369
+ if (!modeAllowed) {
2370
+ setMode("view");
2371
+ }
2372
+ }, [modeAllowed, setMode]);
2373
+ useEffect(() => {
2374
+ if (!notice || notice.error) {
2375
+ return;
2376
+ }
2377
+ const timer = setTimeout(() => setNotice(null), 3e3);
2378
+ return () => clearTimeout(timer);
2379
+ }, [notice]);
2380
+ const value = useMemo(
2381
+ () => ({ comment, depth, mode: activeMode, setMode, notice, setNotice }),
2382
+ [comment, depth, activeMode, setMode, notice]
2383
+ );
2384
+ const name = comment.author ? comment.author.name || comment.author.username : null;
2385
+ const label = comment.deleted || !name ? t("deletedComment") : t("commentBy", { name });
2386
+ const close = () => setMode("view");
2387
+ return /* @__PURE__ */ jsx(ItemContext.Provider, { value, children: /* @__PURE__ */ jsxs(
2388
+ "li",
2389
+ {
2390
+ className: cx(
2391
+ "gc-comment",
2392
+ depth > 0 && "gc-comment--reply",
2393
+ comment.pinned && "gc-comment--pinned",
2394
+ comment.deleted && "gc-comment--deleted",
2395
+ root.highlightId === comment.id && "gc-comment--highlighted",
2396
+ elements.comment,
2397
+ className
2398
+ ),
2399
+ "data-depth": depth,
2400
+ children: [
2401
+ /* @__PURE__ */ jsxs(
2402
+ "article",
2403
+ {
2404
+ ref: articleRef,
2405
+ id: `gc-${comment.id}`,
2406
+ className: "gc-comment__card",
2407
+ tabIndex: -1,
2408
+ "aria-label": label,
2409
+ children: [
2410
+ children ?? /* @__PURE__ */ jsxs(Fragment, { children: [
2411
+ /* @__PURE__ */ jsx(Avatar, {}),
2412
+ /* @__PURE__ */ jsxs("div", { className: "gc-comment__main", children: [
2413
+ /* @__PURE__ */ jsx(Meta, {}),
2414
+ /* @__PURE__ */ jsx(Body, {}),
2415
+ /* @__PURE__ */ jsx(Actions, {})
2416
+ ] })
2417
+ ] }),
2418
+ activeMode === "report" && /* @__PURE__ */ jsx(ReportForm, { onClose: close }),
2419
+ activeMode === "delete" && /* @__PURE__ */ jsx(DeleteConfirm, { onClose: close }),
2420
+ notice && /* @__PURE__ */ jsx(
2421
+ "p",
2422
+ {
2423
+ className: cx("gc-comment__notice", notice.error && "gc-comment__notice--error"),
2424
+ role: notice.error ? "alert" : "status",
2425
+ children: notice.text
2426
+ }
2427
+ )
2428
+ ]
2429
+ }
2430
+ ),
2431
+ activeMode === "reply" && /* @__PURE__ */ jsx(Composer, { replyTo: comment, autoFocus: true, onDone: close }),
2432
+ showReplies && /* @__PURE__ */ jsx(Replies, {})
2433
+ ]
2434
+ }
2435
+ ) });
2436
+ }
2437
+ function Replies() {
2438
+ const root = useRootContext();
2439
+ const { comment, depth } = useItemContext("Comments.Item");
2440
+ const render = useContext(ListContext);
2441
+ const { t, thread, snapshot, maxDepth, elements } = root;
2442
+ if (!thread || !snapshot) {
2443
+ return null;
2444
+ }
2445
+ const children = comment.children;
2446
+ const linkButton = cx("gc-button", "gc-button--link", elements.button);
2447
+ if (depth >= maxDepth) {
2448
+ if (comment.direct_reply_count === 0 && children.length === 0) {
2449
+ return null;
2450
+ }
2451
+ return /* @__PURE__ */ jsx(
2452
+ "button",
2453
+ {
2454
+ type: "button",
2455
+ className: cx(linkButton, "gc-comment__continue"),
2456
+ onClick: () => void root.continueThread(comment.id),
2457
+ children: t("continueThread")
2458
+ }
2459
+ );
2460
+ }
2461
+ const branch = snapshot.branches[comment.id];
2462
+ const remaining = comment.direct_reply_count - children.length;
2463
+ const hasMore = (branch?.hasMore ?? true) && remaining > 0;
2464
+ const name = comment.author?.name || comment.author?.username || "";
2465
+ const loadReplies = async () => {
2466
+ const before = new Set(children.map((child) => child.id));
2467
+ try {
2468
+ await thread.loadReplies(comment.id);
2469
+ } catch (error) {
2470
+ root.reportError("loadReplies", error);
2471
+ return;
2472
+ }
2473
+ const loaded3 = findComment(thread.snapshot().comments, comment.id)?.node.children ?? [];
2474
+ const first = loaded3.find((child) => !before.has(child.id));
2475
+ if (first) {
2476
+ root.focusComment(first.id);
2477
+ }
2478
+ };
2479
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
2480
+ children.length > 0 && /* @__PURE__ */ jsx("ol", { className: "gc-replies", "aria-label": t("repliesTo", { name }), children: children.map((child) => /* @__PURE__ */ jsx(Fragment$1, { children: render(child, depth + 1) }, child.id)) }),
2481
+ hasMore && /* @__PURE__ */ jsx(
2482
+ "button",
2483
+ {
2484
+ type: "button",
2485
+ className: cx(linkButton, "gc-comment__more"),
2486
+ disabled: branch?.loading,
2487
+ "aria-busy": branch?.loading || void 0,
2488
+ onClick: () => void loadReplies(),
2489
+ children: children.length === 0 ? t("loadReplies", { count: remaining }) : t("loadMoreReplies", { count: remaining })
2490
+ }
2491
+ )
2492
+ ] });
2493
+ }
2494
+ function SignIn({ children, className }) {
2495
+ const root = useRootContext("Comments.SignIn");
2496
+ const { t, commenter, elements, client, config, snapshot, signInMode } = root;
2497
+ const titleId = useId();
2498
+ if (children) {
2499
+ return /* @__PURE__ */ jsx(Fragment, { children: children(commenter) });
2500
+ }
2501
+ if (signInMode === "manual") {
2502
+ return null;
2503
+ }
2504
+ const button = cx("gc-button", elements.button);
2505
+ const classes = cx("gc-signin", elements.signIn, className);
2506
+ if (commenter.status === "signed-in" && commenter.user) {
2507
+ const { user } = commenter;
2508
+ return /* @__PURE__ */ jsxs("div", { className: cx(classes, "gc-signin--signed-in"), children: [
2509
+ /* @__PURE__ */ jsx(UserAvatar, { user, className: "gc-avatar--small" }),
2510
+ /* @__PURE__ */ jsx("span", { className: "gc-signin__user", children: t("signedInAs", { username: `@${user.username}` }) }),
2511
+ /* @__PURE__ */ jsx(
2512
+ "button",
2513
+ {
2514
+ type: "button",
2515
+ className: cx(button, "gc-button--link"),
2516
+ onClick: () => void commenter.signOut(),
2517
+ children: t("signOut")
2518
+ }
2519
+ )
2520
+ ] });
2521
+ }
2522
+ const closed = snapshot?.thread.closed ?? false;
2523
+ if (signInMode !== "inline" || !root.writable || closed || !snapshot) {
2524
+ return null;
2525
+ }
2526
+ const forumName = config?.forum.name ?? new URL(client.forum).host;
2527
+ const signingIn = commenter.status === "signing-in";
2528
+ const failure = commenter.error && commenter.error.type !== "cancelled" ? commenter.error.type === "invalid_key" ? t("sessionExpired") : commenter.error.errors[0] ?? t("genericError") : null;
2529
+ return /* @__PURE__ */ jsxs(
2530
+ "div",
2531
+ {
2532
+ className: cx(classes, signingIn && "gc-signin--signing-in"),
2533
+ role: "group",
2534
+ "aria-labelledby": titleId,
2535
+ children: [
2536
+ /* @__PURE__ */ jsx("p", { id: titleId, className: "gc-signin__title", children: t("joinConversation") }),
2537
+ /* @__PURE__ */ jsxs("div", { "aria-live": "polite", children: [
2538
+ signingIn ? /* @__PURE__ */ jsx("p", { className: "gc-signin__body", children: t("signingIn") }) : /* @__PURE__ */ jsx("p", { className: "gc-signin__body", children: t("joinConversationBody", { forum: forumName }) }),
2539
+ failure && !signingIn && /* @__PURE__ */ jsx("p", { className: "gc-signin__error", role: "alert", children: failure })
2540
+ ] }),
2541
+ /* @__PURE__ */ jsx("div", { className: "gc-signin__actions", children: signingIn ? /* @__PURE__ */ jsx("button", { type: "button", className: button, onClick: () => commenter.cancelSignIn(), children: t("signInCancel") }) : /* @__PURE__ */ jsxs(Fragment, { children: [
2542
+ /* @__PURE__ */ jsx(
2543
+ "button",
2544
+ {
2545
+ type: "button",
2546
+ className: cx(button, "gc-button--primary", elements.primaryButton),
2547
+ onClick: () => void commenter.signIn().catch(() => {
2548
+ }),
2549
+ children: t("signIn")
2550
+ }
2551
+ ),
2552
+ commenter.providers.map((provider) => /* @__PURE__ */ jsx(
2553
+ "button",
2554
+ {
2555
+ type: "button",
2556
+ className: button,
2557
+ onClick: () => void commenter.signIn({ provider: provider.name }).catch(() => {
2558
+ }),
2559
+ children: t("signInWith", { provider: provider.title })
2560
+ },
2561
+ provider.name
2562
+ ))
2563
+ ] }) })
2564
+ ]
2565
+ }
2566
+ );
2567
+ }
2568
+ function Notice({ className }) {
2569
+ const { t, snapshot, readOnly, commenter, commentingDenied, elements } = useRootContext("Comments.Notice");
2570
+ if (!snapshot || snapshot.status === "error") {
2571
+ return null;
2572
+ }
2573
+ let text = null;
2574
+ let modifier = "";
2575
+ if (readOnly || commenter.degraded) {
2576
+ text = t("readOnly");
2577
+ modifier = "gc-notice--read-only";
2578
+ } else if (snapshot.thread.closed) {
2579
+ text = t("closed");
2580
+ modifier = "gc-notice--closed";
2581
+ } else if (commentingDenied) {
2582
+ text = t("cannotComment");
2583
+ modifier = "gc-notice--denied";
2584
+ }
2585
+ if (!text) {
2586
+ return null;
2587
+ }
2588
+ return /* @__PURE__ */ jsx("p", { className: cx("gc-notice", modifier, elements.notice, className), role: "status", children: text });
2589
+ }
2590
+ function NewComments({ className }) {
2591
+ const root = useRootContext("Comments.NewComments");
2592
+ const { t, thread, snapshot, elements, focusView } = root;
2593
+ const count = focusView ? 0 : snapshot?.pendingNew ?? 0;
2594
+ const show = () => {
2595
+ if (!thread) {
2596
+ return;
2597
+ }
2598
+ const before = new Set(thread.snapshot().comments.map((comment) => comment.id));
2599
+ thread.showPending();
2600
+ const first = thread.snapshot().comments.find((comment) => !before.has(comment.id));
2601
+ if (first) {
2602
+ root.focusComment(first.id);
2603
+ }
2604
+ };
2605
+ return /* @__PURE__ */ jsx("div", { className: "gc-pill-region", "aria-live": "polite", children: count > 0 && /* @__PURE__ */ jsx(
2606
+ "button",
2607
+ {
2608
+ type: "button",
2609
+ className: cx("gc-button", "gc-pill", elements.button, elements.pill, className),
2610
+ onClick: show,
2611
+ children: t("newComments", { count })
2612
+ }
2613
+ ) });
2614
+ }
2615
+ function AwaitingApproval({ className }) {
2616
+ const { t, snapshot, elements } = useRootContext("Comments.AwaitingApproval");
2617
+ const pending = snapshot?.awaitingApproval ?? [];
2618
+ return /* @__PURE__ */ jsx("div", { className: "gc-pending-region", "aria-live": "polite", children: pending.map((entry, index) => /* @__PURE__ */ jsx(
2619
+ "p",
2620
+ {
2621
+ className: cx("gc-notice", "gc-notice--pending", elements.notice, className),
2622
+ children: entry.message || t("pending")
2623
+ },
2624
+ entry.reviewable_id ?? `pending-${index}`
2625
+ )) });
2626
+ }
2627
+ function LoadMore({ className }) {
2628
+ const root = useRootContext("Comments.LoadMore");
2629
+ const { t, thread, snapshot, elements, focusView } = root;
2630
+ if (!thread || !snapshot?.hasMore || focusView || snapshot.status !== "ready") {
2631
+ return null;
2632
+ }
2633
+ const loadMore = async () => {
2634
+ const before = new Set(thread.snapshot().comments.map((comment) => comment.id));
2635
+ try {
2636
+ await thread.loadMore();
2637
+ } catch (error) {
2638
+ root.reportError("loadMore", error);
2639
+ return;
2640
+ }
2641
+ const first = thread.snapshot().comments.find((comment) => !before.has(comment.id));
2642
+ if (first && !thread.snapshot().hasMore) {
2643
+ root.focusComment(first.id);
2644
+ }
2645
+ };
2646
+ return /* @__PURE__ */ jsx(
2647
+ "button",
2648
+ {
2649
+ type: "button",
2650
+ className: cx("gc-button", "gc-load-more", elements.button, elements.loadMore, className),
2651
+ disabled: snapshot.loadingMore,
2652
+ "aria-busy": snapshot.loadingMore || void 0,
2653
+ onClick: () => void loadMore(),
2654
+ children: snapshot.loadingMore ? t("loading") : t("loadMore")
2655
+ }
2656
+ );
2657
+ }
2658
+ function CommentsSection(props) {
2659
+ return /* @__PURE__ */ jsxs(Root, { ...props, children: [
2660
+ /* @__PURE__ */ jsx(Header, {}),
2661
+ /* @__PURE__ */ jsx(DevPanel, {}),
2662
+ /* @__PURE__ */ jsx(Notice, {}),
2663
+ /* @__PURE__ */ jsx(SignIn, {}),
2664
+ /* @__PURE__ */ jsx(Composer, {}),
2665
+ /* @__PURE__ */ jsx(AwaitingApproval, {}),
2666
+ /* @__PURE__ */ jsx(NewComments, {}),
2667
+ /* @__PURE__ */ jsx(List, {}),
2668
+ /* @__PURE__ */ jsx(LoadMore, {})
2669
+ ] });
2670
+ }
2671
+ var Comments = Object.assign(CommentsSection, {
2672
+ Root,
2673
+ Header,
2674
+ Count,
2675
+ Sort,
2676
+ SignIn,
2677
+ Composer,
2678
+ List,
2679
+ Item,
2680
+ Avatar,
2681
+ Meta,
2682
+ Body,
2683
+ Actions,
2684
+ LoadMore,
2685
+ NewComments,
2686
+ AwaitingApproval,
2687
+ Notice,
2688
+ DevPanel
2689
+ });
2690
+ function useCommentCount(ref, options = {}) {
2691
+ const client = useOptionalCommentsContext(options.forum)?.client;
2692
+ const key = ref ? safeEncode(ref) : null;
2693
+ const latestRef = useRef(ref);
2694
+ latestRef.current = ref;
2695
+ const [state, setState] = useState({
2696
+ key: null,
2697
+ count: null,
2698
+ error: null
2699
+ });
2700
+ useEffect(() => {
2701
+ const current = latestRef.current;
2702
+ if (key === null || !current || !client) {
2703
+ return;
2704
+ }
2705
+ let active = true;
2706
+ client.count(current).then(
2707
+ (count) => active && setState({ key, count, error: null }),
2708
+ (error) => active && setState({ key, count: null, error: toCommentsError(error) })
2709
+ );
2710
+ return () => {
2711
+ active = false;
2712
+ };
2713
+ }, [client, key]);
2714
+ return state.key === key ? { count: state.count, error: state.error } : { count: null, error: null };
2715
+ }
2716
+ function safeEncode(ref) {
2717
+ try {
2718
+ return encodeRef(ref);
2719
+ } catch {
2720
+ return null;
2721
+ }
2722
+ }
2723
+ function CommentCount({
2724
+ identifier,
2725
+ host,
2726
+ url,
2727
+ topicId,
2728
+ forum,
2729
+ render,
2730
+ className
2731
+ }) {
2732
+ const context = useOptionalCommentsContext(forum);
2733
+ const pageUrl = url ?? defaultPageUrl();
2734
+ const ref = topicId !== void 0 ? { topicId } : identifier !== void 0 ? { identifier, host } : pageUrl ? { url: pageUrl } : null;
2735
+ const { count } = useCommentCount(ref, { forum });
2736
+ if (!context) {
2737
+ return null;
2738
+ }
2739
+ const { t } = context;
2740
+ return /* @__PURE__ */ jsx("span", { className: cx("gc-count", className), "aria-busy": count === null || void 0, children: count === null ? null : render ? render(count) : t("commentCount", { count }) });
2741
+ }
2742
+
2743
+ export { CommentCount, Comments, DiscourseComments, useClient, useCommentCount, useCommenter, useForumTheme, useThread };
2744
+ //# sourceMappingURL=react.js.map
2745
+ //# sourceMappingURL=react.js.map