@devaccel-labs/app-comments-toolbar 1.0.0 → 1.3.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.
Files changed (220) hide show
  1. package/README.md +152 -18
  2. package/dist/react.js +516 -61
  3. package/dist/server.js +342 -14
  4. package/dist/types/capture/api.d.ts +30 -0
  5. package/dist/types/capture/editor.d.ts +10 -0
  6. package/dist/types/capture/ignore.d.ts +19 -0
  7. package/dist/types/capture/index.d.ts +1 -0
  8. package/dist/types/capture/picker.d.ts +9 -0
  9. package/dist/types/capture/picture.d.ts +29 -0
  10. package/dist/types/capture/surface.d.ts +39 -0
  11. package/dist/types/core/index.d.ts +2 -2
  12. package/dist/types/core/resolve.d.ts +2 -1
  13. package/dist/types/core/types.d.ts +11 -0
  14. package/dist/types/debug-context.d.ts +7 -0
  15. package/dist/types/dictation.d.ts +9 -0
  16. package/dist/types/embed/controller.d.ts +61 -0
  17. package/dist/types/embed/index.d.ts +1 -0
  18. package/dist/types/embed/options.d.ts +35 -0
  19. package/dist/types/help/index.d.ts +1 -0
  20. package/dist/types/help/panel-index.d.ts +1 -0
  21. package/dist/types/help/panel-styles.d.ts +1 -0
  22. package/dist/types/help/panel.d.ts +4 -0
  23. package/dist/types/help/pill-styles.d.ts +11 -0
  24. package/dist/types/help/pill.d.ts +44 -0
  25. package/dist/types/help/react.d.ts +12 -0
  26. package/dist/types/help/requests.d.ts +2 -0
  27. package/dist/types/help/types.d.ts +119 -0
  28. package/dist/types/help/wire.d.ts +61 -0
  29. package/dist/types/native-wire.d.ts +63 -0
  30. package/dist/types/native.d.ts +43 -0
  31. package/dist/types/react.d.ts +8 -1
  32. package/dist/types/replay.d.ts +6 -0
  33. package/dist/types/runtime/api.d.ts +250 -0
  34. package/dist/types/runtime/attachment-model.d.ts +16 -0
  35. package/dist/types/runtime/attachments.d.ts +43 -7
  36. package/dist/types/runtime/boot.d.ts +39 -0
  37. package/dist/types/runtime/browser-label.d.ts +5 -0
  38. package/dist/types/runtime/build-id.d.ts +7 -0
  39. package/dist/types/runtime/capture-ui.d.ts +45 -21
  40. package/dist/types/runtime/clarity-model.d.ts +260 -0
  41. package/dist/types/runtime/clarity-ui.d.ts +62 -0
  42. package/dist/types/runtime/client.d.ts +6 -197
  43. package/dist/types/runtime/composer-size.d.ts +30 -0
  44. package/dist/types/runtime/dashboard.d.ts +17 -2
  45. package/dist/types/runtime/debug-context-core.d.ts +88 -0
  46. package/dist/types/runtime/debug-context-host.d.ts +39 -0
  47. package/dist/types/runtime/debug-context-model.d.ts +352 -0
  48. package/dist/types/runtime/debug-context-redact.d.ts +48 -0
  49. package/dist/types/runtime/debug-context.d.ts +36 -0
  50. package/dist/types/runtime/details-model.d.ts +7 -0
  51. package/dist/types/runtime/details.d.ts +7 -5
  52. package/dist/types/runtime/dictation-engine.d.ts +98 -0
  53. package/dist/types/runtime/dictation-model.d.ts +28 -0
  54. package/dist/types/runtime/dictation-ui.d.ts +56 -0
  55. package/dist/types/runtime/dictation.d.ts +66 -0
  56. package/dist/types/runtime/draft-dock.d.ts +32 -0
  57. package/dist/types/runtime/draft-model.d.ts +104 -0
  58. package/dist/types/runtime/draft-store.d.ts +15 -0
  59. package/dist/types/runtime/edits.d.ts +1 -1
  60. package/dist/types/runtime/font.d.ts +16 -0
  61. package/dist/types/runtime/format.d.ts +30 -1
  62. package/dist/types/runtime/host-watch.d.ts +20 -0
  63. package/dist/types/runtime/icons.d.ts +16 -2
  64. package/dist/types/runtime/index.d.ts +3 -1
  65. package/dist/types/runtime/ink.d.ts +8 -0
  66. package/dist/types/runtime/kind-model.d.ts +14 -0
  67. package/dist/types/runtime/live-model.d.ts +25 -0
  68. package/dist/types/runtime/loader.d.ts +5 -11
  69. package/dist/types/runtime/markup-editor.d.ts +54 -0
  70. package/dist/types/runtime/markup-model.d.ts +126 -0
  71. package/dist/types/runtime/markup.d.ts +69 -0
  72. package/dist/types/runtime/mention-parse.d.ts +18 -0
  73. package/dist/types/runtime/mentions.d.ts +4 -4
  74. package/dist/types/runtime/page-background.d.ts +9 -0
  75. package/dist/types/runtime/page-image.d.ts +123 -0
  76. package/dist/types/runtime/placement.d.ts +6 -3
  77. package/dist/types/runtime/record.d.ts +18 -2
  78. package/dist/types/runtime/related.d.ts +1 -1
  79. package/dist/types/runtime/replay-model.d.ts +80 -0
  80. package/dist/types/runtime/replay.d.ts +5 -38
  81. package/dist/types/runtime/runtime.d.ts +6 -0
  82. package/dist/types/runtime/screen.d.ts +47 -0
  83. package/dist/types/runtime/screenshot.d.ts +60 -6
  84. package/dist/types/runtime/session-source.d.ts +45 -0
  85. package/dist/types/runtime/session.d.ts +20 -18
  86. package/dist/types/runtime/shield.d.ts +9 -0
  87. package/dist/types/runtime/shot.d.ts +45 -0
  88. package/dist/types/runtime/sketch.d.ts +26 -0
  89. package/dist/types/runtime/styles.d.ts +1 -1
  90. package/dist/types/runtime/sync.d.ts +1 -0
  91. package/dist/types/runtime/thread-model.d.ts +18 -0
  92. package/dist/types/runtime/ticket-form-model.d.ts +57 -0
  93. package/dist/types/runtime/ticket-form.d.ts +9 -0
  94. package/dist/types/runtime/toolbar.d.ts +37 -1
  95. package/dist/types/runtime/ui-scale.d.ts +17 -0
  96. package/dist/types/runtime/update.d.ts +37 -0
  97. package/dist/types/runtime/use-debug-context.d.ts +27 -0
  98. package/dist/types/server/index.d.ts +22 -0
  99. package/dist/types/server/support.d.ts +20 -0
  100. package/dist/types/server.d.ts +2 -0
  101. package/dist/types/vite/plugin.d.ts +15 -0
  102. package/dist/vite.js +121 -41
  103. package/package.json +26 -13
  104. package/src/capture/api.ts +128 -0
  105. package/src/capture/editor.tsx +178 -0
  106. package/src/capture/ignore.ts +38 -0
  107. package/src/capture/index.ts +27 -0
  108. package/src/capture/picker.tsx +171 -0
  109. package/src/capture/picture.ts +90 -0
  110. package/src/capture/surface.tsx +185 -0
  111. package/src/compiler/annotate.ts +202 -0
  112. package/src/compiler/babel.d.ts +11 -0
  113. package/src/compiler/enabled.ts +55 -0
  114. package/src/compiler/index.ts +2 -0
  115. package/src/core/index.ts +8 -0
  116. package/src/core/resolve.ts +307 -0
  117. package/src/core/types.ts +58 -0
  118. package/src/debug-context.ts +47 -0
  119. package/src/dictation.ts +17 -0
  120. package/src/embed/controller.ts +215 -0
  121. package/src/embed/index.ts +68 -0
  122. package/src/embed/options.ts +100 -0
  123. package/src/help/index.ts +38 -0
  124. package/src/help/panel-index.ts +17 -0
  125. package/src/help/panel-styles.ts +123 -0
  126. package/src/help/panel.ts +840 -0
  127. package/src/help/pill-styles.ts +75 -0
  128. package/src/help/pill.ts +338 -0
  129. package/src/help/react.tsx +143 -0
  130. package/src/help/requests.ts +111 -0
  131. package/src/help/types.ts +124 -0
  132. package/src/help/wire.ts +55 -0
  133. package/src/native-wire.ts +124 -0
  134. package/src/native.ts +282 -0
  135. package/src/react.ts +27 -0
  136. package/src/replay.ts +13 -0
  137. package/src/runtime/api.ts +531 -0
  138. package/src/runtime/attachment-model.ts +42 -0
  139. package/src/runtime/attachments.tsx +1260 -0
  140. package/src/runtime/boot.ts +95 -0
  141. package/src/runtime/browser-label.ts +31 -0
  142. package/src/runtime/build-id.ts +10 -0
  143. package/src/runtime/capture-ui.tsx +306 -0
  144. package/src/runtime/capture.ts +508 -0
  145. package/src/runtime/clarity-model.ts +634 -0
  146. package/src/runtime/clarity-ui.tsx +454 -0
  147. package/src/runtime/client.ts +151 -0
  148. package/src/runtime/composer-size.tsx +146 -0
  149. package/src/runtime/dashboard.tsx +1130 -0
  150. package/src/runtime/debug-context-core.ts +681 -0
  151. package/src/runtime/debug-context-host.ts +56 -0
  152. package/src/runtime/debug-context-model.ts +1319 -0
  153. package/src/runtime/debug-context-redact.ts +259 -0
  154. package/src/runtime/debug-context.ts +613 -0
  155. package/src/runtime/details-model.ts +30 -0
  156. package/src/runtime/details.tsx +395 -0
  157. package/src/runtime/dictation-engine.ts +616 -0
  158. package/src/runtime/dictation-model.ts +79 -0
  159. package/src/runtime/dictation-ui.tsx +363 -0
  160. package/src/runtime/dictation.ts +271 -0
  161. package/src/runtime/dock.ts +108 -0
  162. package/src/runtime/draft-dock.tsx +124 -0
  163. package/src/runtime/draft-model.ts +251 -0
  164. package/src/runtime/draft-store.ts +149 -0
  165. package/src/runtime/edits.tsx +165 -0
  166. package/src/runtime/font.ts +42 -0
  167. package/src/runtime/format.ts +129 -0
  168. package/src/runtime/freshness.ts +23 -0
  169. package/src/runtime/handoff.ts +76 -0
  170. package/src/runtime/host-watch.ts +64 -0
  171. package/src/runtime/icons.tsx +295 -0
  172. package/src/runtime/idle.ts +20 -0
  173. package/src/runtime/index.ts +11 -0
  174. package/src/runtime/ink.ts +6 -0
  175. package/src/runtime/kind-model.ts +20 -0
  176. package/src/runtime/live-model.ts +32 -0
  177. package/src/runtime/loader.tsx +87 -0
  178. package/src/runtime/markup-editor.tsx +1333 -0
  179. package/src/runtime/markup-model.ts +485 -0
  180. package/src/runtime/markup.ts +382 -0
  181. package/src/runtime/members.ts +58 -0
  182. package/src/runtime/mention-parse.ts +56 -0
  183. package/src/runtime/mentions.tsx +205 -0
  184. package/src/runtime/page-background.ts +51 -0
  185. package/src/runtime/page-image.ts +1592 -0
  186. package/src/runtime/placement.ts +336 -0
  187. package/src/runtime/record.ts +402 -0
  188. package/src/runtime/redact.ts +30 -0
  189. package/src/runtime/related.ts +34 -0
  190. package/src/runtime/render-page.ts +2 -0
  191. package/src/runtime/replay-model.ts +232 -0
  192. package/src/runtime/replay.ts +173 -0
  193. package/src/runtime/runtime-version.ts +7 -0
  194. package/src/runtime/runtime.tsx +51 -0
  195. package/src/runtime/screen.ts +182 -0
  196. package/src/runtime/screenshot.ts +260 -0
  197. package/src/runtime/session-source.ts +112 -0
  198. package/src/runtime/session.ts +108 -0
  199. package/src/runtime/shield.ts +34 -0
  200. package/src/runtime/shot.tsx +422 -0
  201. package/src/runtime/sketch.ts +139 -0
  202. package/src/runtime/styles.ts +1541 -0
  203. package/src/runtime/sync.ts +300 -0
  204. package/src/runtime/thread-model.ts +71 -0
  205. package/src/runtime/ticket-form-model.ts +128 -0
  206. package/src/runtime/ticket-form.tsx +342 -0
  207. package/src/runtime/toolbar.tsx +4427 -0
  208. package/src/runtime/ui-scale.ts +75 -0
  209. package/src/runtime/update.ts +98 -0
  210. package/src/runtime/use-debug-context.ts +81 -0
  211. package/src/server/index.ts +133 -0
  212. package/src/server/support.ts +499 -0
  213. package/src/server.ts +4 -0
  214. package/src/vite/plugin.ts +102 -0
  215. package/src/vite.ts +1 -0
  216. package/dist/chunks/chunk-CSKXLF33.js +0 -153
  217. package/dist/chunks/chunk-HUM5FQII.js +0 -160
  218. package/dist/chunks/render-page-76MLIT6M.js +0 -1421
  219. package/dist/chunks/runtime-JX75TKER.js +0 -9267
  220. package/dist/chunks/screenshot-66ARTBGR.js +0 -12
@@ -0,0 +1,1130 @@
1
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import type { CommentMessage, Member, Thread } from "./client.js";
3
+ import { replyToThread, updateThread } from "./client.js";
4
+ import { formatSize, useAttachments } from "./attachments.js";
5
+ import { colorFor, initials, type PlacedStatus } from "./format.js";
6
+ import * as Icon from "./icons.js";
7
+ import { MentionTextarea, MessageText, mentionsIn } from "./mentions.js";
8
+ import { relatedTo } from "./related.js";
9
+ import type { Here } from "./screen.js";
10
+ import type { Session } from "./session.js";
11
+ import {
12
+ agoPhrase,
13
+ fromYou,
14
+ involved,
15
+ leadSentence,
16
+ mentionOfYou,
17
+ namesYou,
18
+ } from "./thread-model.js";
19
+
20
+ type TableRow = { thread: Thread; status: PlacedStatus };
21
+ type Pill = "any" | "open" | "resolved" | "mine" | "mentions" | `role:${string}`;
22
+ type PageFilter = "all" | "here" | `route:${string}`;
23
+ type Sort = "new" | "old";
24
+ type Pane = "thread" | "activity" | "related";
25
+ type Menu = "page" | "role" | "sort" | "status" | "more" | null;
26
+ /** The dashboard's sections. "inbox" is the Comments section. */
27
+ export type DashboardScreen = "insights" | "inbox" | "profile" | "settings";
28
+ type Screen = DashboardScreen;
29
+ /** Asks an open or opening dashboard to show this thread in Comments. A new `at` asks again. */
30
+ export type DashboardFocus = { threadId: string; at: number };
31
+
32
+ function Avatar({ name, size = 32 }: { name: string; size?: number }) {
33
+ return (
34
+ <span
35
+ className="cs-avatar"
36
+ style={{ width: size, height: size, fontSize: size * 0.38, background: colorFor(name) }}
37
+ aria-hidden="true"
38
+ >
39
+ {initials(name)}
40
+ </span>
41
+ );
42
+ }
43
+
44
+ export function Dashboard({
45
+ rows,
46
+ here,
47
+ environment,
48
+ memberName,
49
+ memberId,
50
+ roles,
51
+ members,
52
+ session,
53
+ memberToken,
54
+ envFilter,
55
+ profile,
56
+ settingsPane,
57
+ onClose,
58
+ onOpen,
59
+ onReattach,
60
+ onRefresh,
61
+ screen,
62
+ onScreen,
63
+ focus = null,
64
+ readOnly = false,
65
+ }: {
66
+ rows: TableRow[];
67
+ /** The screen the page is on: "This page" is its threads. */
68
+ here: Here;
69
+ environment: string;
70
+ memberName: string;
71
+ memberId?: string;
72
+ roles: string[];
73
+ members: Member[];
74
+ session: Session;
75
+ memberToken: string;
76
+ envFilter: ReactNode;
77
+ profile: ReactNode;
78
+ settingsPane: ReactNode;
79
+ onClose: () => void;
80
+ onOpen: (row: TableRow) => void;
81
+ /** Re-pins a comment from this page that lost its element. */
82
+ onReattach?: (row: TableRow) => void;
83
+ onRefresh: () => Promise<unknown> | unknown;
84
+ screen: Screen;
85
+ onScreen: (screen: Screen) => void;
86
+ focus?: DashboardFocus | null;
87
+ /** View-only access: no replies and no status changes. */
88
+ readOnly?: boolean;
89
+ }) {
90
+ const [pill, setPill] = useState<Pill>("any");
91
+ const [author, setAuthor] = useState<string | null>(null);
92
+ const [page, setPage] = useState<PageFilter>("all");
93
+ const [sort, setSort] = useState<Sort>("new");
94
+ const [menu, setMenu] = useState<Menu>(null);
95
+ const [pane, setPane] = useState<Pane>("thread");
96
+ const [openId, setOpenId] = useState<string | null>(focus?.threadId ?? null);
97
+ const [checked, setChecked] = useState<Set<string>>(() => new Set());
98
+ const [focusedAt, setFocusedAt] = useState<number | null>(focus?.at ?? null);
99
+ if (focus && focus.at !== focusedAt) {
100
+ // Every filter is cleared so the asked-for thread is in the list, resolved or not.
101
+ setFocusedAt(focus.at);
102
+ setPill("any");
103
+ setAuthor(null);
104
+ setPage("all");
105
+ setOpenId(focus.threadId);
106
+ setMenu(null);
107
+ }
108
+ const [reply, setReply] = useState("");
109
+ const [busy, setBusy] = useState(false);
110
+ const [error, setError] = useState("");
111
+ const picked = useRef(new Map<string, Member>());
112
+ const fileInput = useRef<HTMLInputElement>(null);
113
+ const roleNames = useMemo(() => {
114
+ const names = new Set(roles);
115
+ for (const row of rows) if (row.thread.role) names.add(row.thread.role);
116
+ return [...names];
117
+ }, [roles, rows]);
118
+ const pages = useMemo(() => {
119
+ const values = new Set<string>();
120
+ for (const row of rows) values.add(row.thread.url || row.thread.route);
121
+ return [...values].sort((a, b) => a.localeCompare(b));
122
+ }, [rows]);
123
+ const paged = useMemo(
124
+ () =>
125
+ rows.filter(({ thread }) => {
126
+ if (page === "here") return here.has(thread);
127
+ if (page.startsWith("route:")) return (thread.url || thread.route) === page.slice(6);
128
+ return true;
129
+ }),
130
+ [here, page, rows],
131
+ );
132
+ const counts = {
133
+ any: paged.length,
134
+ open: paged.filter((row) => row.thread.status === "open").length,
135
+ resolved: paged.filter((row) => row.thread.status === "resolved").length,
136
+ mentions: paged.filter((row) => mentionOfYou(row.thread, memberId, memberName)).length,
137
+ };
138
+ const authors = useMemo(() => {
139
+ const written = new Map<string, number>();
140
+ for (const row of paged) {
141
+ const name = row.thread.createdBy.trim();
142
+ if (name) written.set(name, (written.get(name) ?? 0) + 1);
143
+ }
144
+ const ordered: Array<[string, number]> = [];
145
+ const seen = new Set<string>();
146
+ for (const member of members) {
147
+ const count = written.get(member.name);
148
+ if (!count || seen.has(member.name)) continue;
149
+ seen.add(member.name);
150
+ ordered.push([member.name, count]);
151
+ }
152
+ for (const [name, count] of [...written.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
153
+ if (seen.has(name)) continue;
154
+ ordered.push([name, count]);
155
+ }
156
+ return ordered;
157
+ }, [members, paged]);
158
+ const insights = useMemo(() => {
159
+ const people = new Map<string, { open: number; resolved: number }>();
160
+ let open = 0;
161
+ let resolved = 0;
162
+ for (const { thread } of rows) {
163
+ if (thread.status === "resolved") resolved += 1;
164
+ else open += 1;
165
+ const name = thread.createdBy.trim() || "Someone";
166
+ const person = people.get(name) ?? { open: 0, resolved: 0 };
167
+ person[thread.status === "resolved" ? "resolved" : "open"] += 1;
168
+ people.set(name, person);
169
+ }
170
+ const byVolume = (
171
+ a: { open: number; resolved: number },
172
+ b: { open: number; resolved: number },
173
+ ) => b.open + b.resolved - (a.open + a.resolved);
174
+ return {
175
+ open,
176
+ resolved,
177
+ people: [...people.entries()]
178
+ .map(([name, counts]) => ({ name, ...counts }))
179
+ .sort((a, b) => byVolume(a, b) || a.name.localeCompare(b.name)),
180
+ recent: [...rows]
181
+ .sort((a, b) => Date.parse(b.thread.updatedAt) - Date.parse(a.thread.updatedAt))
182
+ .slice(0, 6),
183
+ };
184
+ }, [rows]);
185
+ const mentions = useMemo(() => {
186
+ const found: Array<{ row: TableRow; hit: CommentMessage }> = [];
187
+ for (const row of rows) {
188
+ const hit = mentionOfYou(row.thread, memberId, memberName);
189
+ if (hit) found.push({ row, hit });
190
+ }
191
+ found.sort((a, b) => Date.parse(b.hit.createdAt) - Date.parse(a.hit.createdAt));
192
+ return found;
193
+ }, [memberId, memberName, rows]);
194
+ const shown = useMemo(() => {
195
+ const matched = paged.filter(({ thread }) => {
196
+ if (author && thread.createdBy !== author) return false;
197
+ if (pill === "open" || pill === "resolved") return thread.status === pill;
198
+ if (pill === "mine") return involved(thread, memberName);
199
+ if (pill === "mentions") return Boolean(mentionOfYou(thread, memberId, memberName));
200
+ if (pill.startsWith("role:")) return thread.role === pill.slice(5);
201
+ return true;
202
+ });
203
+ return [...matched].sort((a, b) => {
204
+ const order = Date.parse(a.thread.updatedAt) - Date.parse(b.thread.updatedAt);
205
+ return sort === "new" ? -order : order;
206
+ });
207
+ }, [author, memberId, memberName, paged, pill, sort]);
208
+ if (author && !authors.some(([name]) => name === author)) setAuthor(null);
209
+ if (!rows.some((row) => row.thread.id === openId)) setOpenId(shown[0]?.thread.id ?? null);
210
+ useEffect(() => {
211
+ if (!menu) return;
212
+ const close = (event: PointerEvent) => {
213
+ const target = event.target;
214
+ if (target instanceof Element && target.closest(".cs-dash-menu")) return;
215
+ setMenu(null);
216
+ };
217
+ window.addEventListener("pointerdown", close, true);
218
+ return () => window.removeEventListener("pointerdown", close, true);
219
+ }, [menu]);
220
+ const selected = rows.find((row) => row.thread.id === openId) ?? null;
221
+ const linked = Boolean(
222
+ selected && selected.thread.environment && selected.thread.environment !== environment,
223
+ );
224
+ const files = useAttachments(
225
+ session,
226
+ memberToken,
227
+ setError,
228
+ linked ? selected?.thread.environment : undefined,
229
+ );
230
+ const openThreadId = selected?.thread.id ?? null;
231
+ const [replyThreadId, setReplyThreadId] = useState(openThreadId);
232
+ if (openThreadId !== replyThreadId) {
233
+ setReplyThreadId(openThreadId);
234
+ setReply("");
235
+ setError("");
236
+ setPane("thread");
237
+ files.reset();
238
+ }
239
+ useEffect(() => {
240
+ if (openThreadId === null || typeof openThreadId === "string") picked.current = new Map();
241
+ }, [openThreadId]);
242
+
243
+ const choosePill = (next: Pill) => {
244
+ setPill(next);
245
+ setMenu(null);
246
+ };
247
+ const showInbox = (
248
+ next: { pill?: Pill; author?: string | null; page?: PageFilter; threadId?: string } = {},
249
+ ) => {
250
+ setPill(next.pill ?? "any");
251
+ setAuthor(next.author ?? null);
252
+ setPage(next.page ?? "all");
253
+ if (next.threadId) setOpenId(next.threadId);
254
+ setMenu(null);
255
+ onScreen("inbox");
256
+ };
257
+ const pageLabel = page === "all" ? "All pages" : page === "here" ? "This page" : page.slice(6);
258
+ const roleLabel = pill.startsWith("role:") ? pill.slice(5) : "All roles";
259
+ const first = selected?.thread.messages[0];
260
+ const shots = first?.attachments?.filter((file) => file.contentType.startsWith("image/")) ?? [];
261
+ const clips = first?.attachments?.filter((file) => file.contentType.startsWith("video/")) ?? [];
262
+ const related = selected
263
+ ? relatedTo(
264
+ selected.thread,
265
+ rows.map((row) => row.thread),
266
+ )
267
+ : [];
268
+
269
+ const addFiles = (list: FileList | null) => {
270
+ if (!list?.length) return;
271
+ files.add([...list].map((file) => ({ blob: file, name: file.name })));
272
+ };
273
+
274
+ const send = async () => {
275
+ if (!selected || busy || files.uploading || (!reply.trim() && !files.ids.length)) return;
276
+ setBusy(true);
277
+ setError("");
278
+ const mentions = mentionsIn(reply, picked.current);
279
+ try {
280
+ await replyToThread(session, memberToken, selected.thread.id, reply.trim(), {
281
+ mentions: mentions.map((person) => person.id),
282
+ attachments: files.ids,
283
+ });
284
+ setReply("");
285
+ picked.current = new Map();
286
+ files.reset();
287
+ await onRefresh();
288
+ } catch (caught) {
289
+ setError(caught instanceof Error ? caught.message : "Couldn’t reply.");
290
+ }
291
+ setBusy(false);
292
+ };
293
+
294
+ const setStatus = async (status: "open" | "resolved") => {
295
+ if (!selected || linked || selected.thread.status === status) {
296
+ setMenu(null);
297
+ return;
298
+ }
299
+ setMenu(null);
300
+ setBusy(true);
301
+ setError("");
302
+ try {
303
+ await updateThread(session, memberToken, selected.thread.id, { status });
304
+ await onRefresh();
305
+ } catch (caught) {
306
+ setError(caught instanceof Error ? caught.message : "Couldn’t update.");
307
+ }
308
+ setBusy(false);
309
+ };
310
+
311
+ const toggleMenu = (name: Menu) => setMenu((current) => (current === name ? null : name));
312
+
313
+ return (
314
+ <>
315
+ <div className="cs-dash-scrim cs-hit" onClick={onClose} />
316
+ <section
317
+ className="cs-card cs-dashboard cs-hit"
318
+ data-cs-ui="dashboard-panel"
319
+ aria-label="Dashboard"
320
+ >
321
+ <nav className="cs-dash-sidebar" aria-label="Dashboard sections">
322
+ <p className="cs-dash-sidebar-env">{environment}</p>
323
+ {(
324
+ [
325
+ ["insights", "Dashboard", <Icon.Board key="i" />, mentions.length || null],
326
+ ["inbox", "Comments", <Icon.Inbox key="i" />, insights.open || null],
327
+ ["profile", "Profile", <Icon.User key="i" />, null],
328
+ ["settings", "Settings", <Icon.Gear key="i" />, null],
329
+ ] as const
330
+ ).map(([value, label, icon, badge]) => (
331
+ <button
332
+ key={value}
333
+ type="button"
334
+ className={screen === value ? "cs-dash-nav is-on" : "cs-dash-nav"}
335
+ data-cs-ui={`dashboard-nav-${value}`}
336
+ aria-current={screen === value ? "page" : undefined}
337
+ onClick={() => (value === "inbox" ? showInbox() : onScreen(value))}
338
+ >
339
+ {icon}
340
+ <span>{label}</span>
341
+ {badge ? (
342
+ <em title={value === "insights" ? "Comments that @mention you" : undefined}>
343
+ {value === "insights" ? `@${badge}` : badge}
344
+ </em>
345
+ ) : null}
346
+ </button>
347
+ ))}
348
+ </nav>
349
+ <div className="cs-dash-main">
350
+ {screen === "profile" || screen === "settings" ? (
351
+ <>
352
+ <header className="cs-dash-top">
353
+ <h2 className="cs-insight-title">
354
+ {screen === "profile" ? "Profile" : "Settings"}
355
+ </h2>
356
+ <div className="cs-dash-tools">
357
+ <button
358
+ className="cs-dash-icon"
359
+ type="button"
360
+ aria-label="Close dashboard"
361
+ onClick={onClose}
362
+ >
363
+ <Icon.Close />
364
+ </button>
365
+ </div>
366
+ </header>
367
+ <div className="cs-insights">
368
+ <div className="cs-dash-pane" data-cs-ui={`dashboard-${screen}`}>
369
+ {screen === "profile" ? profile : settingsPane}
370
+ </div>
371
+ </div>
372
+ </>
373
+ ) : screen === "insights" ? (
374
+ <>
375
+ <header className="cs-dash-top">
376
+ <h2 className="cs-insight-title">Dashboard</h2>
377
+ {envFilter}
378
+ <div className="cs-dash-tools">
379
+ <button
380
+ className="cs-dash-icon"
381
+ type="button"
382
+ aria-label="Close dashboard"
383
+ onClick={onClose}
384
+ >
385
+ <Icon.Close />
386
+ </button>
387
+ </div>
388
+ </header>
389
+ <div className="cs-insights" data-cs-ui="dashboard-insights">
390
+ {rows.length === 0 ? <p className="cs-dash-empty">No comments yet.</p> : null}
391
+ {rows.length > 0 ? (
392
+ <>
393
+ <div className="cs-insight-stats">
394
+ <button
395
+ type="button"
396
+ className="cs-insight-stat"
397
+ onClick={() => showInbox({ pill: "open" })}
398
+ >
399
+ <b>{insights.open}</b>
400
+ <span>Open</span>
401
+ </button>
402
+ <button
403
+ type="button"
404
+ className="cs-insight-stat"
405
+ onClick={() => showInbox({ pill: "resolved" })}
406
+ >
407
+ <b>{insights.resolved}</b>
408
+ <span>Resolved</span>
409
+ </button>
410
+ <button
411
+ type="button"
412
+ className={
413
+ mentions.length ? "cs-insight-stat is-mention" : "cs-insight-stat"
414
+ }
415
+ data-cs-ui="dashboard-mentions"
416
+ onClick={() => showInbox({ pill: "mentions" })}
417
+ >
418
+ <b>{mentions.length}</b>
419
+ <span>Mentions</span>
420
+ </button>
421
+ <div className="cs-insight-stat">
422
+ <b>{insights.people.length}</b>
423
+ <span>{insights.people.length === 1 ? "Person" : "People"}</span>
424
+ </div>
425
+ <div className="cs-insight-stat">
426
+ <b>{new Set(rows.map((row) => row.thread.url || row.thread.route)).size}</b>
427
+ <span>Pages</span>
428
+ </div>
429
+ </div>
430
+ {mentions.length > 0 ? (
431
+ <section className="cs-insight-card" data-cs-ui="dashboard-mention-list">
432
+ <header>
433
+ <h3>You were mentioned</h3>
434
+ </header>
435
+ <div className="cs-insight-recent">
436
+ {mentions.slice(0, 6).map(({ row, hit }) => (
437
+ <button
438
+ key={row.thread.id}
439
+ type="button"
440
+ className="cs-insight-row"
441
+ onClick={() =>
442
+ showInbox({ pill: "mentions", threadId: row.thread.id })
443
+ }
444
+ >
445
+ <Avatar name={hit.author} size={21} />
446
+ <span className="cs-insight-who">
447
+ <strong>{leadSentence(hit.body) || "Mentioned you"}</strong>
448
+ <small>{hit.author} mentioned you</small>
449
+ </span>
450
+ <em>{agoPhrase(hit.createdAt)}</em>
451
+ </button>
452
+ ))}
453
+ </div>
454
+ </section>
455
+ ) : null}
456
+ <section className="cs-insight-card">
457
+ <header>
458
+ <h3>Who’s writing</h3>
459
+ <p className="cs-insight-legend">
460
+ <i className="is-open" /> Open <i className="is-done" /> Resolved
461
+ </p>
462
+ </header>
463
+ <div className="cs-insight-list">
464
+ {insights.people.map((person) => {
465
+ const total = person.open + person.resolved;
466
+ const scale = insights.people[0]
467
+ ? insights.people[0].open + insights.people[0].resolved
468
+ : 1;
469
+ return (
470
+ <button
471
+ key={person.name}
472
+ type="button"
473
+ className="cs-insight-row"
474
+ onClick={() => showInbox({ author: person.name })}
475
+ >
476
+ <Avatar name={person.name} size={21} />
477
+ <span className="cs-insight-who">
478
+ <strong>{person.name}</strong>
479
+ <span className="cs-insight-bar" aria-hidden="true">
480
+ <i
481
+ className="is-open"
482
+ style={{ width: `${(person.open / scale) * 100}%` }}
483
+ />
484
+ <i
485
+ className="is-done"
486
+ style={{ width: `${(person.resolved / scale) * 100}%` }}
487
+ />
488
+ </span>
489
+ </span>
490
+ <em>{total}</em>
491
+ </button>
492
+ );
493
+ })}
494
+ </div>
495
+ </section>
496
+ <section className="cs-insight-card">
497
+ <header>
498
+ <h3>Recent</h3>
499
+ </header>
500
+ <div className="cs-insight-recent">
501
+ {insights.recent.map((row) => (
502
+ <button
503
+ key={row.thread.id}
504
+ type="button"
505
+ className="cs-insight-row"
506
+ onClick={() => showInbox({ threadId: row.thread.id })}
507
+ >
508
+ <Avatar name={row.thread.createdBy} size={21} />
509
+ <span className="cs-insight-who">
510
+ <strong>
511
+ {leadSentence(row.thread.messages[0]?.body ?? "Comment")}
512
+ </strong>
513
+ <small>{row.thread.createdBy}</small>
514
+ </span>
515
+ <em>{agoPhrase(row.thread.updatedAt)}</em>
516
+ </button>
517
+ ))}
518
+ </div>
519
+ </section>
520
+ </>
521
+ ) : null}
522
+ </div>
523
+ </>
524
+ ) : (
525
+ <>
526
+ <header className="cs-dash-top">
527
+ <div className="cs-dash-pills">
528
+ <div role="radiogroup" aria-label="Status">
529
+ {(
530
+ [
531
+ ["any", `All (${counts.any})`],
532
+ ["open", `Open (${counts.open})`],
533
+ ["resolved", `Resolved (${counts.resolved})`],
534
+ ["mine", "Mine"],
535
+ ["mentions", `Mentions (${counts.mentions})`],
536
+ ] as const
537
+ ).map(([value, label]) => (
538
+ <button
539
+ key={value}
540
+ type="button"
541
+ role="radio"
542
+ aria-checked={pill === value}
543
+ data-cs-ui="dashboard-status"
544
+ data-status={value}
545
+ className={pill === value ? "cs-dash-pill is-on" : "cs-dash-pill"}
546
+ onClick={() => choosePill(value)}
547
+ >
548
+ {label}
549
+ </button>
550
+ ))}
551
+ </div>
552
+ {authors.length > 0 ? <span className="cs-dash-sep" aria-hidden="true" /> : null}
553
+ {authors.map(([name, count]) => (
554
+ <button
555
+ key={name}
556
+ type="button"
557
+ aria-pressed={author === name}
558
+ data-cs-ui="dashboard-author"
559
+ data-author={name}
560
+ className={author === name ? "cs-dash-pill is-on" : "cs-dash-pill"}
561
+ onClick={() => setAuthor((current) => (current === name ? null : name))}
562
+ >
563
+ <Avatar name={name} size={14} />
564
+ {name} ({count})
565
+ </button>
566
+ ))}
567
+ </div>
568
+ {envFilter}
569
+ <div className="cs-dash-tools">
570
+ <div className="cs-dash-menu">
571
+ <button
572
+ type="button"
573
+ className="cs-dash-pill"
574
+ data-cs-ui="dashboard-page"
575
+ aria-expanded={menu === "page"}
576
+ onClick={() => toggleMenu("page")}
577
+ >
578
+ {pageLabel}
579
+ <Icon.ChevronDown />
580
+ </button>
581
+ {menu === "page" ? (
582
+ <div className="cs-dash-menu-pop" role="menu">
583
+ {(
584
+ [
585
+ ["all", "All pages"],
586
+ ["here", "This page"],
587
+ ] as const
588
+ ).map(([value, label]) => (
589
+ <button
590
+ key={value}
591
+ type="button"
592
+ className={page === value ? "is-on" : ""}
593
+ onClick={() => {
594
+ setPage(value);
595
+ setMenu(null);
596
+ }}
597
+ >
598
+ {label}
599
+ </button>
600
+ ))}
601
+ {pages.map((value) => (
602
+ <button
603
+ key={value}
604
+ type="button"
605
+ className={page === `route:${value}` ? "is-on" : ""}
606
+ onClick={() => {
607
+ setPage(`route:${value}`);
608
+ setMenu(null);
609
+ }}
610
+ >
611
+ {value}
612
+ </button>
613
+ ))}
614
+ </div>
615
+ ) : null}
616
+ </div>
617
+ <div className="cs-dash-menu">
618
+ <button
619
+ type="button"
620
+ className="cs-dash-pill"
621
+ data-cs-ui="dashboard-roles"
622
+ aria-expanded={menu === "role"}
623
+ onClick={() => toggleMenu("role")}
624
+ >
625
+ {roleLabel}
626
+ <Icon.ChevronDown />
627
+ </button>
628
+ {menu === "role" ? (
629
+ <div className="cs-dash-menu-pop" role="menu">
630
+ <button
631
+ type="button"
632
+ className={pill.startsWith("role:") ? "" : "is-on"}
633
+ onClick={() => {
634
+ if (pill.startsWith("role:")) choosePill("any");
635
+ else setMenu(null);
636
+ }}
637
+ >
638
+ All roles
639
+ </button>
640
+ {roleNames.map((role) => (
641
+ <button
642
+ key={role}
643
+ type="button"
644
+ data-cs-ui="dashboard-role"
645
+ data-role={role}
646
+ className={pill === `role:${role}` ? "is-on" : ""}
647
+ onClick={() => choosePill(`role:${role}`)}
648
+ >
649
+ {role}
650
+ </button>
651
+ ))}
652
+ </div>
653
+ ) : null}
654
+ </div>
655
+ <div className="cs-dash-menu">
656
+ <button
657
+ type="button"
658
+ className="cs-dash-pill"
659
+ data-cs-ui="dashboard-sort"
660
+ aria-expanded={menu === "sort"}
661
+ onClick={() => toggleMenu("sort")}
662
+ >
663
+ {sort === "new" ? "Newest first" : "Oldest first"}
664
+ <Icon.ChevronDown />
665
+ </button>
666
+ {menu === "sort" ? (
667
+ <div className="cs-dash-menu-pop" role="menu">
668
+ <button
669
+ type="button"
670
+ className={sort === "new" ? "is-on" : ""}
671
+ onClick={() => {
672
+ setSort("new");
673
+ setMenu(null);
674
+ }}
675
+ >
676
+ Newest first
677
+ </button>
678
+ <button
679
+ type="button"
680
+ className={sort === "old" ? "is-on" : ""}
681
+ onClick={() => {
682
+ setSort("old");
683
+ setMenu(null);
684
+ }}
685
+ >
686
+ Oldest first
687
+ </button>
688
+ </div>
689
+ ) : null}
690
+ </div>
691
+ <button
692
+ className={pane === "activity" ? "cs-dash-icon is-on" : "cs-dash-icon"}
693
+ type="button"
694
+ aria-label="Activity"
695
+ onClick={() =>
696
+ setPane((current) => (current === "activity" ? "thread" : "activity"))
697
+ }
698
+ >
699
+ <Icon.Clock />
700
+ </button>
701
+ <div className="cs-dash-menu">
702
+ <button
703
+ className="cs-dash-icon"
704
+ type="button"
705
+ aria-label="More"
706
+ aria-expanded={menu === "more"}
707
+ onClick={() => toggleMenu("more")}
708
+ >
709
+ <Icon.Ellipsis />
710
+ </button>
711
+ {menu === "more" && selected ? (
712
+ <div className="cs-dash-menu-pop" role="menu">
713
+ <button
714
+ type="button"
715
+ onClick={() => {
716
+ setMenu(null);
717
+ onOpen(selected);
718
+ }}
719
+ >
720
+ Show on page
721
+ </button>
722
+ {onReattach &&
723
+ !readOnly &&
724
+ !linked &&
725
+ selected.thread.status === "open" &&
726
+ here.has(selected.thread) &&
727
+ selected.status !== "pinned" &&
728
+ selected.status !== "drifted" ? (
729
+ <button
730
+ type="button"
731
+ data-cs-ui="reattach"
732
+ onClick={() => {
733
+ setMenu(null);
734
+ onReattach(selected);
735
+ }}
736
+ >
737
+ Reattach
738
+ </button>
739
+ ) : null}
740
+ </div>
741
+ ) : null}
742
+ </div>
743
+ <button
744
+ className="cs-dash-icon"
745
+ type="button"
746
+ aria-label="Close dashboard"
747
+ onClick={onClose}
748
+ >
749
+ <Icon.Close />
750
+ </button>
751
+ </div>
752
+ </header>
753
+ <div className="cs-dash-body">
754
+ <div className="cs-dash-list">
755
+ {shown.length === 0 ? <p className="cs-dash-empty">No comments match.</p> : null}
756
+ {shown.map((row) => {
757
+ const { thread } = row;
758
+ const message = thread.messages[0];
759
+ const title = leadSentence(message?.body ?? "Comment");
760
+ const rest = (message?.body ?? "").trim().slice(title.length).trim();
761
+ const yours = mentionOfYou(thread, memberId, memberName);
762
+ return (
763
+ <article
764
+ key={thread.id}
765
+ className={thread.id === openId ? "cs-dash-row is-on" : "cs-dash-row"}
766
+ data-cs-ui="dashboard-row"
767
+ data-thread-id={thread.id}
768
+ data-mentioned={yours ? "true" : undefined}
769
+ >
770
+ <input
771
+ className="cs-dash-check"
772
+ type="checkbox"
773
+ aria-label={`Select ${title}`}
774
+ checked={checked.has(thread.id)}
775
+ onChange={() =>
776
+ setChecked((current) => {
777
+ const next = new Set(current);
778
+ if (next.has(thread.id)) next.delete(thread.id);
779
+ else next.add(thread.id);
780
+ return next;
781
+ })
782
+ }
783
+ onClick={(event) => event.stopPropagation()}
784
+ />
785
+ <button
786
+ type="button"
787
+ className="cs-dash-open"
788
+ onClick={() => setOpenId(thread.id)}
789
+ >
790
+ <Avatar name={thread.createdBy} />
791
+ <span className="cs-dash-copy">
792
+ <strong>{title}</strong>
793
+ {rest ? <p>{rest}</p> : null}
794
+ {yours ? (
795
+ <span className="cs-dash-tags">
796
+ <span
797
+ className="cs-dash-tag is-mention"
798
+ data-cs-ui="dashboard-mention"
799
+ >
800
+ @ you
801
+ </span>
802
+ </span>
803
+ ) : null}
804
+ </span>
805
+ <span className="cs-dash-side">
806
+ <b>{thread.createdBy}</b>
807
+ <time dateTime={thread.updatedAt}>{agoPhrase(thread.updatedAt)}</time>
808
+ <span
809
+ className={
810
+ thread.status === "resolved"
811
+ ? "cs-dash-state is-done"
812
+ : "cs-dash-state"
813
+ }
814
+ >
815
+ <span className="cs-dash-dot" />
816
+ {thread.status === "resolved" ? "Resolved" : "Open"}
817
+ </span>
818
+ </span>
819
+ </button>
820
+ </article>
821
+ );
822
+ })}
823
+ </div>
824
+ <div className="cs-dash-detail">
825
+ {selected && first ? (
826
+ <>
827
+ <div className="cs-dash-detail-scroll">
828
+ <div className="cs-dash-title">
829
+ <h2>{leadSentence(first.body)}</h2>
830
+ <div className="cs-dash-menu">
831
+ <button
832
+ type="button"
833
+ className="cs-dash-pill cs-dash-status"
834
+ aria-label="Status"
835
+ aria-expanded={menu === "status"}
836
+ disabled={linked || readOnly}
837
+ onClick={() => toggleMenu("status")}
838
+ >
839
+ <span
840
+ className={
841
+ selected.thread.status === "resolved"
842
+ ? "cs-dash-state is-done"
843
+ : "cs-dash-state"
844
+ }
845
+ >
846
+ <span className="cs-dash-dot" />
847
+ {selected.thread.status === "resolved" ? "Resolved" : "Open"}
848
+ </span>
849
+ <Icon.ChevronDown />
850
+ </button>
851
+ {menu === "status" ? (
852
+ <div className="cs-dash-menu-pop" role="menu">
853
+ <button type="button" onClick={() => void setStatus("open")}>
854
+ Open
855
+ </button>
856
+ <button type="button" onClick={() => void setStatus("resolved")}>
857
+ Resolved
858
+ </button>
859
+ </div>
860
+ ) : null}
861
+ </div>
862
+ </div>
863
+ {mentionOfYou(selected.thread, memberId, memberName) ? (
864
+ <div className="cs-dash-tags">
865
+ <span className="cs-dash-tag is-mention">@ you</span>
866
+ </div>
867
+ ) : null}
868
+ <div className="cs-dash-meta">
869
+ <Avatar name={selected.thread.createdBy} size={21} />
870
+ <strong>{selected.thread.createdBy}</strong>
871
+ <time dateTime={selected.thread.createdAt}>
872
+ {agoPhrase(selected.thread.createdAt)}
873
+ </time>
874
+ <span className="cs-dash-tag">{selected.thread.role ?? "All users"}</span>
875
+ </div>
876
+ <p className="cs-dash-body-text">
877
+ <MessageText body={first.body} mentions={first.mentions} />
878
+ </p>
879
+ <div className="cs-dash-media">
880
+ {shots.map((file) => (
881
+ <a
882
+ key={file.id}
883
+ className="cs-dash-shot"
884
+ href={file.url}
885
+ target="_blank"
886
+ rel="noreferrer noopener"
887
+ >
888
+ <img src={file.url} alt={file.name} />
889
+ </a>
890
+ ))}
891
+ {clips.map((file) => (
892
+ <a
893
+ key={file.id}
894
+ className="cs-dash-file"
895
+ href={file.url}
896
+ target="_blank"
897
+ rel="noreferrer noopener"
898
+ >
899
+ <span className="cs-dash-play">
900
+ <Icon.Play />
901
+ </span>
902
+ <span>
903
+ <strong>{file.name}</strong>
904
+ <small>{formatSize(file.size)}</small>
905
+ </span>
906
+ </a>
907
+ ))}
908
+ <button
909
+ className="cs-dash-add"
910
+ type="button"
911
+ onClick={() => fileInput.current?.click()}
912
+ >
913
+ <Icon.Camera />+ Add screenshot
914
+ </button>
915
+ </div>
916
+ <div className="cs-dash-tabs" role="tablist">
917
+ {(
918
+ [
919
+ [
920
+ "thread",
921
+ `Thread (${Math.max(0, selected.thread.messages.length - 1)})`,
922
+ ],
923
+ ["activity", "Activity"],
924
+ ["related", "Related"],
925
+ ] as const
926
+ ).map(([value, label]) => (
927
+ <button
928
+ key={value}
929
+ type="button"
930
+ role="tab"
931
+ aria-selected={pane === value}
932
+ className={pane === value ? "is-on" : ""}
933
+ onClick={() => setPane(value)}
934
+ >
935
+ {label}
936
+ </button>
937
+ ))}
938
+ </div>
939
+ {pane === "thread" ? (
940
+ <div className="cs-dash-thread" role="tabpanel">
941
+ {selected.thread.messages.slice(1).length === 0 ? (
942
+ <p className="cs-dash-empty">No replies yet.</p>
943
+ ) : null}
944
+ {selected.thread.messages.slice(1).map((message) => (
945
+ <div key={message.id} className="cs-dash-note">
946
+ <Avatar name={message.author} size={21} />
947
+ <div>
948
+ <header>
949
+ <strong>{message.author}</strong>
950
+ <time dateTime={message.createdAt}>
951
+ {agoPhrase(message.createdAt)}
952
+ </time>
953
+ {namesYou(message, memberId, memberName) &&
954
+ !fromYou(message, memberId, memberName) ? (
955
+ <span className="cs-dash-tag is-mention">@ you</span>
956
+ ) : null}
957
+ </header>
958
+ {message.body ? (
959
+ <p>
960
+ <MessageText
961
+ body={message.body}
962
+ mentions={message.mentions}
963
+ />
964
+ </p>
965
+ ) : null}
966
+ <div className="cs-dash-media">
967
+ {(message.attachments ?? [])
968
+ .filter(
969
+ (file) =>
970
+ file.contentType.startsWith("video/") ||
971
+ file.contentType.startsWith("image/"),
972
+ )
973
+ .map((file) =>
974
+ file.contentType.startsWith("image/") ? (
975
+ <a
976
+ key={file.id}
977
+ className="cs-dash-shot"
978
+ href={file.url}
979
+ target="_blank"
980
+ rel="noreferrer noopener"
981
+ >
982
+ <img src={file.url} alt={file.name} />
983
+ </a>
984
+ ) : (
985
+ <a
986
+ key={file.id}
987
+ className="cs-dash-file"
988
+ href={file.url}
989
+ target="_blank"
990
+ rel="noreferrer noopener"
991
+ >
992
+ <span className="cs-dash-play">
993
+ <Icon.Play />
994
+ </span>
995
+ <span>
996
+ <strong>{file.name}</strong>
997
+ <small>{formatSize(file.size)}</small>
998
+ </span>
999
+ </a>
1000
+ ),
1001
+ )}
1002
+ </div>
1003
+ </div>
1004
+ </div>
1005
+ ))}
1006
+ </div>
1007
+ ) : null}
1008
+ {pane === "activity" ? (
1009
+ <div className="cs-dash-thread" role="tabpanel">
1010
+ {selected.thread.messages.map((message, index) => (
1011
+ <div key={message.id} className="cs-dash-note">
1012
+ <Avatar name={message.author} size={21} />
1013
+ <div>
1014
+ <header>
1015
+ <strong>{message.author}</strong>
1016
+ <time dateTime={message.createdAt}>
1017
+ {agoPhrase(message.createdAt)}
1018
+ </time>
1019
+ </header>
1020
+ <p>{index === 0 ? "Started this comment" : "Replied"}</p>
1021
+ </div>
1022
+ </div>
1023
+ ))}
1024
+ </div>
1025
+ ) : null}
1026
+ {pane === "related" ? (
1027
+ <div className="cs-dash-thread" role="tabpanel">
1028
+ {related.length === 0 ? (
1029
+ <p className="cs-dash-empty">
1030
+ Nothing else on this page or this component.
1031
+ </p>
1032
+ ) : null}
1033
+ {related.map(({ thread, because }) => (
1034
+ <button
1035
+ key={thread.id}
1036
+ type="button"
1037
+ className="cs-dash-file"
1038
+ onClick={() => setOpenId(thread.id)}
1039
+ >
1040
+ <Avatar name={thread.createdBy} size={21} />
1041
+ <span>
1042
+ <strong>
1043
+ {leadSentence(thread.messages[0]?.body ?? "Comment")}
1044
+ </strong>
1045
+ <small>{because}</small>
1046
+ </span>
1047
+ </button>
1048
+ ))}
1049
+ </div>
1050
+ ) : null}
1051
+ {error ? <p className="cs-error">{error}</p> : null}
1052
+ {linked ? (
1053
+ <p className="cs-hint">Resolve this in {selected.thread.environment}.</p>
1054
+ ) : null}
1055
+ </div>
1056
+ {readOnly ? null : (
1057
+ <form
1058
+ className="cs-dash-reply"
1059
+ onSubmit={(event) => {
1060
+ event.preventDefault();
1061
+ void send();
1062
+ }}
1063
+ >
1064
+ <Avatar name={memberName || "You"} size={21} />
1065
+ <MentionTextarea
1066
+ aria-label="Reply"
1067
+ placeholder="Write a reply…"
1068
+ rows={1}
1069
+ value={reply}
1070
+ members={members}
1071
+ onValueChange={setReply}
1072
+ onMention={(person) => picked.current.set(person.id, person)}
1073
+ />
1074
+ <input
1075
+ ref={fileInput}
1076
+ type="file"
1077
+ accept="image/*,video/*"
1078
+ multiple
1079
+ hidden
1080
+ onChange={(event) => {
1081
+ addFiles(event.target.files);
1082
+ event.target.value = "";
1083
+ }}
1084
+ />
1085
+ <button
1086
+ className="cs-icon-button"
1087
+ type="button"
1088
+ aria-label="Attach a file"
1089
+ onClick={() => fileInput.current?.click()}
1090
+ >
1091
+ <Icon.Paperclip />
1092
+ </button>
1093
+ <button
1094
+ className="cs-icon-button"
1095
+ type="button"
1096
+ aria-label="Mention someone"
1097
+ onClick={() =>
1098
+ setReply(
1099
+ (current) =>
1100
+ `${current}${current && !current.endsWith(" ") ? " " : ""}@`,
1101
+ )
1102
+ }
1103
+ >
1104
+ @
1105
+ </button>
1106
+ <button
1107
+ className="cs-dash-send"
1108
+ type="submit"
1109
+ aria-label="Send reply"
1110
+ disabled={
1111
+ busy || files.uploading || (!reply.trim() && !files.ids.length)
1112
+ }
1113
+ >
1114
+ <Icon.Plane />
1115
+ </button>
1116
+ </form>
1117
+ )}
1118
+ </>
1119
+ ) : (
1120
+ <p className="cs-dash-empty">No comment selected.</p>
1121
+ )}
1122
+ </div>
1123
+ </div>
1124
+ </>
1125
+ )}
1126
+ </div>
1127
+ </section>
1128
+ </>
1129
+ );
1130
+ }