@devaccel-labs/app-comments-toolbar 1.1.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 (214) hide show
  1. package/README.md +148 -30
  2. package/dist/react.js +417 -64
  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 +7 -0
  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 +1 -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 +2 -0
  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 -3
  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 +64 -11
  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/screen.d.ts +47 -0
  82. package/dist/types/runtime/screenshot.d.ts +60 -8
  83. package/dist/types/runtime/session-source.d.ts +45 -0
  84. package/dist/types/runtime/session.d.ts +20 -18
  85. package/dist/types/runtime/shield.d.ts +9 -0
  86. package/dist/types/runtime/shot.d.ts +45 -0
  87. package/dist/types/runtime/sketch.d.ts +26 -0
  88. package/dist/types/runtime/styles.d.ts +1 -1
  89. package/dist/types/runtime/sync.d.ts +1 -0
  90. package/dist/types/runtime/thread-model.d.ts +18 -0
  91. package/dist/types/runtime/ticket-form-model.d.ts +57 -0
  92. package/dist/types/runtime/ticket-form.d.ts +9 -0
  93. package/dist/types/runtime/toolbar.d.ts +37 -1
  94. package/dist/types/runtime/ui-scale.d.ts +17 -0
  95. package/dist/types/runtime/update.d.ts +32 -8
  96. package/dist/types/runtime/use-debug-context.d.ts +27 -0
  97. package/dist/types/server/index.d.ts +22 -0
  98. package/dist/types/server/support.d.ts +20 -0
  99. package/dist/types/server.d.ts +2 -0
  100. package/dist/types/vite/plugin.d.ts +15 -0
  101. package/dist/vite.js +121 -41
  102. package/package.json +24 -11
  103. package/src/capture/api.ts +128 -0
  104. package/src/capture/editor.tsx +178 -0
  105. package/src/capture/ignore.ts +38 -0
  106. package/src/capture/index.ts +27 -0
  107. package/src/capture/picker.tsx +171 -0
  108. package/src/capture/picture.ts +90 -0
  109. package/src/capture/surface.tsx +185 -0
  110. package/src/compiler/annotate.ts +202 -0
  111. package/src/compiler/babel.d.ts +11 -0
  112. package/src/compiler/enabled.ts +55 -0
  113. package/src/compiler/index.ts +2 -0
  114. package/src/core/index.ts +8 -0
  115. package/src/core/resolve.ts +307 -0
  116. package/src/core/types.ts +58 -0
  117. package/src/debug-context.ts +47 -0
  118. package/src/dictation.ts +17 -0
  119. package/src/embed/controller.ts +215 -0
  120. package/src/embed/index.ts +68 -0
  121. package/src/embed/options.ts +100 -0
  122. package/src/help/index.ts +38 -0
  123. package/src/help/panel-index.ts +17 -0
  124. package/src/help/panel-styles.ts +123 -0
  125. package/src/help/panel.ts +840 -0
  126. package/src/help/pill-styles.ts +75 -0
  127. package/src/help/pill.ts +338 -0
  128. package/src/help/react.tsx +143 -0
  129. package/src/help/requests.ts +111 -0
  130. package/src/help/types.ts +124 -0
  131. package/src/help/wire.ts +55 -0
  132. package/src/native-wire.ts +124 -0
  133. package/src/native.ts +282 -0
  134. package/src/react.ts +27 -0
  135. package/src/replay.ts +13 -0
  136. package/src/runtime/api.ts +531 -0
  137. package/src/runtime/attachment-model.ts +42 -0
  138. package/src/runtime/attachments.tsx +1260 -0
  139. package/src/runtime/boot.ts +95 -0
  140. package/src/runtime/browser-label.ts +31 -0
  141. package/src/runtime/build-id.ts +10 -0
  142. package/src/runtime/capture-ui.tsx +306 -0
  143. package/src/runtime/capture.ts +508 -0
  144. package/src/runtime/clarity-model.ts +634 -0
  145. package/src/runtime/clarity-ui.tsx +454 -0
  146. package/src/runtime/client.ts +151 -0
  147. package/src/runtime/composer-size.tsx +146 -0
  148. package/src/runtime/dashboard.tsx +1130 -0
  149. package/src/runtime/debug-context-core.ts +681 -0
  150. package/src/runtime/debug-context-host.ts +56 -0
  151. package/src/runtime/debug-context-model.ts +1319 -0
  152. package/src/runtime/debug-context-redact.ts +259 -0
  153. package/src/runtime/debug-context.ts +613 -0
  154. package/src/runtime/details-model.ts +30 -0
  155. package/src/runtime/details.tsx +395 -0
  156. package/src/runtime/dictation-engine.ts +616 -0
  157. package/src/runtime/dictation-model.ts +79 -0
  158. package/src/runtime/dictation-ui.tsx +363 -0
  159. package/src/runtime/dictation.ts +271 -0
  160. package/src/runtime/dock.ts +108 -0
  161. package/src/runtime/draft-dock.tsx +124 -0
  162. package/src/runtime/draft-model.ts +251 -0
  163. package/src/runtime/draft-store.ts +149 -0
  164. package/src/runtime/edits.tsx +165 -0
  165. package/src/runtime/font.ts +42 -0
  166. package/src/runtime/format.ts +129 -0
  167. package/src/runtime/freshness.ts +23 -0
  168. package/src/runtime/handoff.ts +76 -0
  169. package/src/runtime/host-watch.ts +64 -0
  170. package/src/runtime/icons.tsx +295 -0
  171. package/src/runtime/idle.ts +20 -0
  172. package/src/runtime/index.ts +11 -0
  173. package/src/runtime/ink.ts +6 -0
  174. package/src/runtime/kind-model.ts +20 -0
  175. package/src/runtime/live-model.ts +32 -0
  176. package/src/runtime/loader.tsx +87 -0
  177. package/src/runtime/markup-editor.tsx +1333 -0
  178. package/src/runtime/markup-model.ts +485 -0
  179. package/src/runtime/markup.ts +382 -0
  180. package/src/runtime/members.ts +58 -0
  181. package/src/runtime/mention-parse.ts +56 -0
  182. package/src/runtime/mentions.tsx +205 -0
  183. package/src/runtime/page-background.ts +51 -0
  184. package/src/runtime/page-image.ts +1592 -0
  185. package/src/runtime/placement.ts +336 -0
  186. package/src/runtime/record.ts +402 -0
  187. package/src/runtime/redact.ts +30 -0
  188. package/src/runtime/related.ts +34 -0
  189. package/src/runtime/render-page.ts +2 -0
  190. package/src/runtime/replay-model.ts +232 -0
  191. package/src/runtime/replay.ts +173 -0
  192. package/src/runtime/runtime-version.ts +7 -0
  193. package/src/runtime/runtime.tsx +51 -0
  194. package/src/runtime/screen.ts +182 -0
  195. package/src/runtime/screenshot.ts +260 -0
  196. package/src/runtime/session-source.ts +112 -0
  197. package/src/runtime/session.ts +108 -0
  198. package/src/runtime/shield.ts +34 -0
  199. package/src/runtime/shot.tsx +422 -0
  200. package/src/runtime/sketch.ts +139 -0
  201. package/src/runtime/styles.ts +1541 -0
  202. package/src/runtime/sync.ts +300 -0
  203. package/src/runtime/thread-model.ts +71 -0
  204. package/src/runtime/ticket-form-model.ts +128 -0
  205. package/src/runtime/ticket-form.tsx +342 -0
  206. package/src/runtime/toolbar.tsx +4427 -0
  207. package/src/runtime/ui-scale.ts +75 -0
  208. package/src/runtime/update.ts +98 -0
  209. package/src/runtime/use-debug-context.ts +81 -0
  210. package/src/server/index.ts +133 -0
  211. package/src/server/support.ts +499 -0
  212. package/src/server.ts +4 -0
  213. package/src/vite/plugin.ts +102 -0
  214. package/src/vite.ts +1 -0
@@ -0,0 +1,165 @@
1
+ import { useRef, useState, type KeyboardEvent } from "react";
2
+ import type { CommentMessage, Member } from "./client.js";
3
+ import { ago } from "./format.js";
4
+ import { MentionTextarea, MessageText, mentionsIn } from "./mentions.js";
5
+ import { versionsOf } from "./thread-model.js";
6
+
7
+ export { isMine } from "./thread-model.js";
8
+
9
+ export function MessageTools({
10
+ message,
11
+ mine,
12
+ historyOpen,
13
+ onHistory,
14
+ onEdit,
15
+ }: {
16
+ message: CommentMessage;
17
+ mine: boolean;
18
+ historyOpen: boolean;
19
+ onHistory: () => void;
20
+ onEdit: () => void;
21
+ }) {
22
+ const edits = message.revisions?.length ?? 0;
23
+ if (!edits && !mine) return null;
24
+ return (
25
+ <span className="cs-message-tools">
26
+ {edits ? (
27
+ <button
28
+ type="button"
29
+ className="cs-link"
30
+ data-cs-ui="message-history"
31
+ aria-expanded={historyOpen}
32
+ title={
33
+ message.editedAt ? `Edited ${new Date(message.editedAt).toLocaleString()}` : undefined
34
+ }
35
+ onClick={onHistory}
36
+ >
37
+ edited · v{edits + 1}
38
+ </button>
39
+ ) : null}
40
+ {mine ? (
41
+ <button type="button" className="cs-link" data-cs-ui="message-edit" onClick={onEdit}>
42
+ Edit
43
+ </button>
44
+ ) : null}
45
+ </span>
46
+ );
47
+ }
48
+
49
+ export function MessageHistory({ message }: { message: CommentMessage }) {
50
+ const versions = versionsOf(message);
51
+ return (
52
+ <ol className="cs-history" data-cs-ui="history" aria-label="Edit history">
53
+ {versions.map((version, index) => (
54
+ <li
55
+ key={index}
56
+ className={index === versions.length - 1 ? "is-current" : undefined}
57
+ data-cs-ui="history-version"
58
+ >
59
+ <div className="cs-history-meta">
60
+ <strong>v{index + 1}</strong>
61
+ <time dateTime={version.createdAt} title={new Date(version.createdAt).toLocaleString()}>
62
+ {ago(version.createdAt)}
63
+ </time>
64
+ {index === versions.length - 1 ? <span>current</span> : null}
65
+ </div>
66
+ <p className="cs-text">
67
+ {version.body ? (
68
+ <MessageText body={version.body} mentions={version.mentions} />
69
+ ) : (
70
+ <em>No text</em>
71
+ )}
72
+ </p>
73
+ </li>
74
+ ))}
75
+ </ol>
76
+ );
77
+ }
78
+
79
+ export function MessageEditor({
80
+ message,
81
+ members,
82
+ selfEmail,
83
+ allowEmpty,
84
+ onSave,
85
+ onCancel,
86
+ }: {
87
+ message: CommentMessage;
88
+ members: Member[];
89
+ selfEmail?: string;
90
+ allowEmpty: boolean;
91
+ onSave: (body: string, mentions: string[]) => Promise<unknown>;
92
+ onCancel: () => void;
93
+ }) {
94
+ const [text, setText] = useState(message.body);
95
+ const [busy, setBusy] = useState(false);
96
+ const picked = useRef(
97
+ new Map<string, Member>(
98
+ (message.mentions ?? []).map((mention) => [mention.id, { ...mention, email: "" }]),
99
+ ),
100
+ );
101
+ const caretPlaced = useRef(false);
102
+ const unchanged = text.trim() === message.body.trim();
103
+ const canSave = !busy && !unchanged && (allowEmpty || Boolean(text.trim()));
104
+
105
+ const save = () => {
106
+ if (!canSave) return;
107
+ setBusy(true);
108
+ onSave(
109
+ text.trim(),
110
+ mentionsIn(text, picked.current).map((person) => person.id),
111
+ ).finally(() => setBusy(false));
112
+ };
113
+
114
+ return (
115
+ <form
116
+ className="cs-edit"
117
+ data-cs-ui="message-editor"
118
+ onSubmit={(event) => {
119
+ event.preventDefault();
120
+ save();
121
+ }}
122
+ >
123
+ <MentionTextarea
124
+ data-cs-ui="message-editor-body"
125
+ aria-label="Edit comment"
126
+ autoFocus
127
+ value={text}
128
+ onValueChange={setText}
129
+ members={members}
130
+ selfEmail={selfEmail}
131
+ onMention={(person) => picked.current.set(person.id, person)}
132
+ onFocus={(event) => {
133
+ if (caretPlaced.current) return;
134
+ caretPlaced.current = true;
135
+ const end = event.currentTarget.value.length;
136
+ event.currentTarget.setSelectionRange(end, end);
137
+ }}
138
+ onKeyDown={(event: KeyboardEvent<HTMLTextAreaElement>) => {
139
+ if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
140
+ event.preventDefault();
141
+ save();
142
+ } else if (event.key === "Escape") {
143
+ event.preventDefault();
144
+ event.stopPropagation();
145
+ onCancel();
146
+ }
147
+ }}
148
+ />
149
+ <div className="cs-edit-foot">
150
+ <span className="cs-hint">{busy ? "Saving…" : "⌘↵ to save · Esc to cancel"}</span>
151
+ <button type="button" className="cs-button" onClick={onCancel}>
152
+ Cancel
153
+ </button>
154
+ <button
155
+ type="submit"
156
+ className="cs-button is-primary"
157
+ data-cs-ui="message-editor-save"
158
+ disabled={!canSave}
159
+ >
160
+ Save
161
+ </button>
162
+ </div>
163
+ </form>
164
+ );
165
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The toolbar's typeface. It draws inside other people's pages, on every system, so it brings its own font
3
+ * instead of taking whichever one the system has: Inter's Latin cut, served beside the runtime
4
+ * (`scripts/build-toolbar.mjs` copies it there). Until it has loaded, the system's interface font stands in
5
+ * (`styles.ts`).
6
+ *
7
+ * The bytes are fetched like every other request to the service and handed to the page's fonts, rather than
8
+ * named in an `@font-face` rule: a page's Content-Security-Policy already lets the toolbar reach the service
9
+ * (`connect-src`), and a font built from bytes needs no `font-src` for it (`docs/integration`'s policy has none).
10
+ */
11
+ export const FONT_FILE = "fonts/inter-latin-wght-normal.woff2";
12
+ export const FONT_FAMILY = "Comment Toolbar Inter";
13
+
14
+ /** Where the service at `apiUrl` serves the font; null when that is not an address. */
15
+ export function fontUrl(apiUrl: string): string | null {
16
+ try {
17
+ return new URL(`${apiUrl.replace(/\/+$/, "")}/v1/toolbar/${FONT_FILE}`).href;
18
+ } catch {
19
+ return null;
20
+ }
21
+ }
22
+
23
+ let asked = false;
24
+
25
+ /** Adds the toolbar's font to the page once; a build that replaced this one finds it already there. */
26
+ export function loadFont(apiUrl: string) {
27
+ const url = fontUrl(apiUrl);
28
+ if (asked || url === null || typeof FontFace === "undefined" || !document.fonts) return;
29
+ if ([...document.fonts].some((face) => face.family.replace(/"/g, "") === FONT_FAMILY)) return;
30
+ asked = true;
31
+ void fetch(url, { mode: "cors", credentials: "omit" })
32
+ .then((response) => (response.ok ? response.arrayBuffer() : Promise.reject(response.status)))
33
+ .then((bytes) => {
34
+ const face = new FontFace(FONT_FAMILY, bytes, { weight: "100 900", style: "normal" });
35
+ document.fonts.add(face);
36
+ return face.load();
37
+ })
38
+ .catch(() => {
39
+ // The system's font stays; the next session may try again.
40
+ asked = false;
41
+ });
42
+ }
@@ -0,0 +1,129 @@
1
+ import type { Anchor } from "../core/index.js";
2
+ import type { Delivery } from "./api.js";
3
+
4
+ const palette = [
5
+ "#007aff",
6
+ "#34c759",
7
+ "#ff9500",
8
+ "#af52de",
9
+ "#ff2d55",
10
+ "#5ac8fa",
11
+ "#5856d6",
12
+ "#a2845e",
13
+ ];
14
+
15
+ export function initials(name: string) {
16
+ const parts = name.trim().split(/\s+/).filter(Boolean);
17
+ const first = parts[0] ?? "?";
18
+ const last = parts[parts.length - 1] ?? "";
19
+ const letters = parts.length > 1 ? `${first[0] ?? ""}${last[0] ?? ""}` : first.slice(0, 2);
20
+ return letters.toUpperCase();
21
+ }
22
+
23
+ export function colorFor(name: string) {
24
+ let hash = 0;
25
+ for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
26
+ return palette[Math.abs(hash) % palette.length];
27
+ }
28
+
29
+ export function ago(iso: string) {
30
+ const seconds = Math.max(0, (Date.now() - Date.parse(iso)) / 1000);
31
+ if (seconds < 60) return "now";
32
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
33
+ if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
34
+ if (seconds < 86400 * 30) return `${Math.floor(seconds / 86400)}d`;
35
+ return new Date(iso).toLocaleDateString();
36
+ }
37
+
38
+ export function targetName(anchor: Anchor) {
39
+ const owner = anchor.component?.name ?? anchor.tag;
40
+ const label = (anchor.accName || anchor.text || "").trim();
41
+ return label ? `${owner} › ${label.length > 40 ? `${label.slice(0, 40)}…` : label}` : owner;
42
+ }
43
+
44
+ /** "pending": on this page, but not looked up yet. */
45
+ export type PlacedStatus =
46
+ | "pinned"
47
+ | "drifted"
48
+ | "hidden"
49
+ | "ambiguous"
50
+ | "unavailable"
51
+ | "resolved"
52
+ | "pending";
53
+
54
+ /** Where the open pin card sits. A resolved thread leaves the screen. An open pin uses its
55
+ * current spot, and one that only lost its box for a moment keeps the last spot. */
56
+ export function openPinCardSpot(
57
+ thread: { id: string; status: "open" | "resolved" } | undefined,
58
+ live: { id: string; x: number; y: number } | null,
59
+ held: { id: string; x: number; y: number } | null,
60
+ ) {
61
+ if (!thread || thread.status === "resolved") return null;
62
+ if (live?.id === thread.id) return live;
63
+ if (held?.id === thread.id) return held;
64
+ return null;
65
+ }
66
+
67
+ /**
68
+ * Keep a status the actor just set when a colder thread list still has the previous one.
69
+ * A list that already shows the painted status drops that pending entry.
70
+ */
71
+ export function paintThreadStatuses<T extends { id: string; status: "open" | "resolved" }>(
72
+ threads: readonly T[],
73
+ pending: ReadonlyMap<string, "open" | "resolved">,
74
+ ): { threads: T[]; pending: Map<string, "open" | "resolved"> } {
75
+ const nextPending = new Map<string, "open" | "resolved">();
76
+ const painted = threads.map((thread) => {
77
+ const status = pending.get(thread.id);
78
+ if (!status || thread.status === status) return thread;
79
+ nextPending.set(thread.id, status);
80
+ return { ...thread, status };
81
+ });
82
+ return { threads: painted, pending: nextPending };
83
+ }
84
+
85
+ /** Plain-language reason a thread isn't pinned, or null when there's nothing to explain. */
86
+ export function statusNote(
87
+ status: PlacedStatus,
88
+ anchor: Anchor,
89
+ ): { text: string; tone: string } | null {
90
+ switch (status) {
91
+ case "pinned":
92
+ case "resolved":
93
+ case "pending":
94
+ case "unavailable":
95
+ return null;
96
+ case "drifted":
97
+ return { text: "Text changed", tone: "warn" };
98
+ case "hidden":
99
+ return {
100
+ text: anchor.overlay ? `Inside “${anchor.overlay.name}”` : "Not visible right now",
101
+ tone: "quiet",
102
+ };
103
+ case "ambiguous":
104
+ return { text: "Matches more than one element", tone: "warn" };
105
+ default: {
106
+ const never: never = status;
107
+ return never;
108
+ }
109
+ }
110
+ }
111
+
112
+ function names(people: Array<{ name: string }>) {
113
+ const list = people.map((person) => person.name);
114
+ return list.length > 1 ? `${list.slice(0, -1).join(", ")} and ${list.at(-1)}` : (list[0] ?? "");
115
+ }
116
+
117
+ /** One quiet line about who was emailed for a message, or null when nobody was mentioned. */
118
+ export function deliveryText(delivery: Delivery | undefined) {
119
+ if (!delivery) return null;
120
+ const parts: string[] = [];
121
+ if (delivery.emailed.length) parts.push(`Emailed ${names(delivery.emailed)}`);
122
+ const limited = delivery.skipped.filter((item) => item.reason === "rate_limited");
123
+ if (limited.length) parts.push(`Hourly email limit reached, ${names(limited)} not emailed`);
124
+ const muted = delivery.skipped.filter((item) => item.reason === "muted");
125
+ if (muted.length) parts.push(`${names(muted)} turned off mention emails`);
126
+ if (delivery.skipped.some((item) => item.reason === "disabled"))
127
+ parts.push("Email isn’t set up for this app");
128
+ return parts.length ? parts.join(" · ") : null;
129
+ }
@@ -0,0 +1,23 @@
1
+ /** Longest wait between threads refetches while no live socket is open and something on screen needs them. */
2
+ export const MAX_POLL_MS = 60_000;
3
+
4
+ export type PollState = {
5
+ /** The live socket is open, so changes are pushed. */
6
+ live: boolean;
7
+ visible: boolean;
8
+ /** Something on screen shows threads: the comments panel, a draft, or this page's pins and count. */
9
+ wanted: boolean;
10
+ /** Refetches in a row that changed nothing. */
11
+ quiet: number;
12
+ pollMs: number;
13
+ };
14
+
15
+ /**
16
+ * Milliseconds until the next threads refetch, or null for none: the live socket pushes changes, the tab is hidden,
17
+ * or the toolbar is collapsed with nothing on this page to keep current. Each refetch that changed nothing doubles
18
+ * the wait, up to MAX_POLL_MS (or `pollMs`, if that is longer).
19
+ */
20
+ export function nextPoll({ live, visible, wanted, quiet, pollMs }: PollState): number | null {
21
+ if (live || !visible || !wanted) return null;
22
+ return Math.min(pollMs * 2 ** quiet, Math.max(pollMs, MAX_POLL_MS));
23
+ }
@@ -0,0 +1,76 @@
1
+ const key = "devaccel.comment.handoff";
2
+ const ttl = 30 * 60 * 1000;
3
+
4
+ export type Handoff = { invite?: string; thread?: string; at: number };
5
+
6
+ export function readHandoff(): Handoff | null {
7
+ try {
8
+ const value = JSON.parse(sessionStorage.getItem(key) ?? "null") as Handoff | null;
9
+ return value && Date.now() - value.at < ttl ? value : null;
10
+ } catch {
11
+ return null;
12
+ }
13
+ }
14
+
15
+ function write(value: Handoff) {
16
+ try {
17
+ if (value.invite || value.thread) sessionStorage.setItem(key, JSON.stringify(value));
18
+ else sessionStorage.removeItem(key);
19
+ } catch {
20
+ // Storage can be unavailable (private mode, sandboxed frames); the link then only works on this load.
21
+ }
22
+ }
23
+
24
+ function linkParams() {
25
+ const url = new URL(window.location.href);
26
+ const hash = new URLSearchParams(url.hash.slice(1));
27
+ return {
28
+ url,
29
+ hash,
30
+ invite: url.searchParams.get("cs-invite") ?? hash.get("cs-invite"),
31
+ thread: url.searchParams.get("cs-thread") ?? hash.get("cs-thread"),
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Saves invite and thread links for this tab before the app's router or sign-in flow rewrites the URL.
37
+ * Runs when the module loads, which is before hydration, and again when the toolbar mounts.
38
+ */
39
+ export function captureHandoff() {
40
+ if (typeof window === "undefined") return;
41
+ const { invite, thread } = linkParams();
42
+ if (!invite && !thread) return;
43
+ write({
44
+ ...readHandoff(),
45
+ ...(invite ? { invite } : {}),
46
+ ...(thread ? { thread } : {}),
47
+ at: Date.now(),
48
+ });
49
+ }
50
+
51
+ export function dropHandoff(part: "invite" | "thread") {
52
+ const current = readHandoff();
53
+ if (!current) return;
54
+ const { [part]: _dropped, ...rest } = current;
55
+ write(rest);
56
+ }
57
+
58
+ /** Removes the link params from the address bar once they're saved. */
59
+ export function stripHandoffFromUrl() {
60
+ const { url, hash } = linkParams();
61
+ const inQuery = url.searchParams.has("cs-invite") || url.searchParams.has("cs-thread");
62
+ const inHash = hash.has("cs-invite") || hash.has("cs-thread");
63
+ if (!inQuery && !inHash) return;
64
+ url.searchParams.delete("cs-invite");
65
+ url.searchParams.delete("cs-thread");
66
+ if (inHash) url.hash = "";
67
+ history.replaceState(history.state, "", url);
68
+ }
69
+
70
+ export function linkWith(href: string, name: "cs-invite" | "cs-thread", value: string) {
71
+ const url = new URL(href, window.location.origin);
72
+ url.searchParams.delete("cs-invite");
73
+ url.searchParams.delete("cs-thread");
74
+ url.hash = `${name}=${encodeURIComponent(value)}`;
75
+ return url.toString();
76
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * When to ask the host app's session route again, so the toolbar follows a sign-in or a sign-out that did not
3
+ * load a page. The route is only asked when something a person does could have changed who is signed in: the
4
+ * page comes back into view or gets the focus, the app navigates (its router, the back button, a hash route),
5
+ * or the browser restores the page from its back-forward cache. Never on a timer, and never more often than
6
+ * once per `HOST_CHECK_GAP_MS`: a moment inside the gap is answered at its end.
7
+ */
8
+
9
+ /** The shortest time between two questions to the host's session route. */
10
+ export const HOST_CHECK_GAP_MS = 10_000;
11
+
12
+ /** Dispatched on `window` after the app calls `history.pushState` or `history.replaceState`. */
13
+ export const NAVIGATED = "cs-nav";
14
+ const HISTORY_FLAG = "__devaccelCommentHistory";
15
+
16
+ /** Makes the app's own `pushState` and `replaceState` dispatch `NAVIGATED`. Once per page, whoever asks. */
17
+ export function announceNavigation(): void {
18
+ if (Reflect.get(window, HISTORY_FLAG)) return;
19
+ Reflect.set(window, HISTORY_FLAG, true);
20
+ for (const name of ["pushState", "replaceState"] as const) {
21
+ const original = history[name].bind(history);
22
+ history[name] = (...args: Parameters<History["pushState"]>) => {
23
+ original(...args);
24
+ window.dispatchEvent(new Event(NAVIGATED));
25
+ };
26
+ }
27
+ }
28
+
29
+ export type HostWatch = {
30
+ /** Asks now, whatever the gap: the host said its sign-in changed. */
31
+ now(): void;
32
+ stop(): void;
33
+ };
34
+
35
+ /** Calls `check` at the moments above. The first one is a gap after this call: the caller has just asked. */
36
+ export function watchHost(check: () => void, gap = HOST_CHECK_GAP_MS): HostWatch {
37
+ announceNavigation();
38
+ const listening = new AbortController();
39
+ let last = Date.now();
40
+ let timer = 0;
41
+ const run = () => {
42
+ window.clearTimeout(timer);
43
+ timer = 0;
44
+ last = Date.now();
45
+ check();
46
+ };
47
+ const soon = (event: Event) => {
48
+ if (timer || document.visibilityState === "hidden") return;
49
+ if (event.type === "pageshow" && !(event as PageTransitionEvent).persisted) return;
50
+ const wait = last + gap - Date.now();
51
+ if (wait <= 0) run();
52
+ else timer = window.setTimeout(run, wait);
53
+ };
54
+ for (const type of ["focus", "popstate", "hashchange", "pageshow", NAVIGATED])
55
+ window.addEventListener(type, soon, { signal: listening.signal });
56
+ document.addEventListener("visibilitychange", soon, { signal: listening.signal });
57
+ return {
58
+ now: run,
59
+ stop() {
60
+ window.clearTimeout(timer);
61
+ listening.abort();
62
+ },
63
+ };
64
+ }