@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.
- package/README.md +148 -30
- package/dist/react.js +417 -64
- package/dist/server.js +342 -14
- package/dist/types/capture/api.d.ts +30 -0
- package/dist/types/capture/editor.d.ts +10 -0
- package/dist/types/capture/ignore.d.ts +19 -0
- package/dist/types/capture/index.d.ts +1 -0
- package/dist/types/capture/picker.d.ts +9 -0
- package/dist/types/capture/picture.d.ts +29 -0
- package/dist/types/capture/surface.d.ts +39 -0
- package/dist/types/core/index.d.ts +2 -2
- package/dist/types/core/resolve.d.ts +2 -1
- package/dist/types/core/types.d.ts +11 -0
- package/dist/types/debug-context.d.ts +7 -0
- package/dist/types/dictation.d.ts +9 -0
- package/dist/types/embed/controller.d.ts +61 -0
- package/dist/types/embed/index.d.ts +1 -0
- package/dist/types/embed/options.d.ts +35 -0
- package/dist/types/help/index.d.ts +1 -0
- package/dist/types/help/panel-index.d.ts +1 -0
- package/dist/types/help/panel-styles.d.ts +1 -0
- package/dist/types/help/panel.d.ts +4 -0
- package/dist/types/help/pill-styles.d.ts +11 -0
- package/dist/types/help/pill.d.ts +44 -0
- package/dist/types/help/react.d.ts +12 -0
- package/dist/types/help/requests.d.ts +2 -0
- package/dist/types/help/types.d.ts +119 -0
- package/dist/types/help/wire.d.ts +61 -0
- package/dist/types/native-wire.d.ts +63 -0
- package/dist/types/native.d.ts +43 -0
- package/dist/types/react.d.ts +7 -0
- package/dist/types/replay.d.ts +6 -0
- package/dist/types/runtime/api.d.ts +250 -0
- package/dist/types/runtime/attachment-model.d.ts +16 -0
- package/dist/types/runtime/attachments.d.ts +43 -7
- package/dist/types/runtime/boot.d.ts +39 -0
- package/dist/types/runtime/browser-label.d.ts +5 -0
- package/dist/types/runtime/build-id.d.ts +1 -0
- package/dist/types/runtime/capture-ui.d.ts +45 -21
- package/dist/types/runtime/clarity-model.d.ts +260 -0
- package/dist/types/runtime/clarity-ui.d.ts +62 -0
- package/dist/types/runtime/client.d.ts +6 -197
- package/dist/types/runtime/composer-size.d.ts +30 -0
- package/dist/types/runtime/dashboard.d.ts +17 -2
- package/dist/types/runtime/debug-context-core.d.ts +88 -0
- package/dist/types/runtime/debug-context-host.d.ts +39 -0
- package/dist/types/runtime/debug-context-model.d.ts +352 -0
- package/dist/types/runtime/debug-context-redact.d.ts +48 -0
- package/dist/types/runtime/debug-context.d.ts +36 -0
- package/dist/types/runtime/details-model.d.ts +7 -0
- package/dist/types/runtime/details.d.ts +7 -5
- package/dist/types/runtime/dictation-engine.d.ts +98 -0
- package/dist/types/runtime/dictation-model.d.ts +28 -0
- package/dist/types/runtime/dictation-ui.d.ts +56 -0
- package/dist/types/runtime/dictation.d.ts +66 -0
- package/dist/types/runtime/draft-dock.d.ts +32 -0
- package/dist/types/runtime/draft-model.d.ts +104 -0
- package/dist/types/runtime/draft-store.d.ts +15 -0
- package/dist/types/runtime/edits.d.ts +1 -1
- package/dist/types/runtime/font.d.ts +16 -0
- package/dist/types/runtime/format.d.ts +30 -1
- package/dist/types/runtime/host-watch.d.ts +20 -0
- package/dist/types/runtime/icons.d.ts +16 -2
- package/dist/types/runtime/index.d.ts +2 -0
- package/dist/types/runtime/ink.d.ts +8 -0
- package/dist/types/runtime/kind-model.d.ts +14 -0
- package/dist/types/runtime/live-model.d.ts +25 -0
- package/dist/types/runtime/loader.d.ts +5 -3
- package/dist/types/runtime/markup-editor.d.ts +54 -0
- package/dist/types/runtime/markup-model.d.ts +126 -0
- package/dist/types/runtime/markup.d.ts +69 -0
- package/dist/types/runtime/mention-parse.d.ts +18 -0
- package/dist/types/runtime/mentions.d.ts +4 -4
- package/dist/types/runtime/page-background.d.ts +9 -0
- package/dist/types/runtime/page-image.d.ts +64 -11
- package/dist/types/runtime/placement.d.ts +6 -3
- package/dist/types/runtime/record.d.ts +18 -2
- package/dist/types/runtime/related.d.ts +1 -1
- package/dist/types/runtime/replay-model.d.ts +80 -0
- package/dist/types/runtime/replay.d.ts +5 -38
- package/dist/types/runtime/screen.d.ts +47 -0
- package/dist/types/runtime/screenshot.d.ts +60 -8
- package/dist/types/runtime/session-source.d.ts +45 -0
- package/dist/types/runtime/session.d.ts +20 -18
- package/dist/types/runtime/shield.d.ts +9 -0
- package/dist/types/runtime/shot.d.ts +45 -0
- package/dist/types/runtime/sketch.d.ts +26 -0
- package/dist/types/runtime/styles.d.ts +1 -1
- package/dist/types/runtime/sync.d.ts +1 -0
- package/dist/types/runtime/thread-model.d.ts +18 -0
- package/dist/types/runtime/ticket-form-model.d.ts +57 -0
- package/dist/types/runtime/ticket-form.d.ts +9 -0
- package/dist/types/runtime/toolbar.d.ts +37 -1
- package/dist/types/runtime/ui-scale.d.ts +17 -0
- package/dist/types/runtime/update.d.ts +32 -8
- package/dist/types/runtime/use-debug-context.d.ts +27 -0
- package/dist/types/server/index.d.ts +22 -0
- package/dist/types/server/support.d.ts +20 -0
- package/dist/types/server.d.ts +2 -0
- package/dist/types/vite/plugin.d.ts +15 -0
- package/dist/vite.js +121 -41
- package/package.json +24 -11
- package/src/capture/api.ts +128 -0
- package/src/capture/editor.tsx +178 -0
- package/src/capture/ignore.ts +38 -0
- package/src/capture/index.ts +27 -0
- package/src/capture/picker.tsx +171 -0
- package/src/capture/picture.ts +90 -0
- package/src/capture/surface.tsx +185 -0
- package/src/compiler/annotate.ts +202 -0
- package/src/compiler/babel.d.ts +11 -0
- package/src/compiler/enabled.ts +55 -0
- package/src/compiler/index.ts +2 -0
- package/src/core/index.ts +8 -0
- package/src/core/resolve.ts +307 -0
- package/src/core/types.ts +58 -0
- package/src/debug-context.ts +47 -0
- package/src/dictation.ts +17 -0
- package/src/embed/controller.ts +215 -0
- package/src/embed/index.ts +68 -0
- package/src/embed/options.ts +100 -0
- package/src/help/index.ts +38 -0
- package/src/help/panel-index.ts +17 -0
- package/src/help/panel-styles.ts +123 -0
- package/src/help/panel.ts +840 -0
- package/src/help/pill-styles.ts +75 -0
- package/src/help/pill.ts +338 -0
- package/src/help/react.tsx +143 -0
- package/src/help/requests.ts +111 -0
- package/src/help/types.ts +124 -0
- package/src/help/wire.ts +55 -0
- package/src/native-wire.ts +124 -0
- package/src/native.ts +282 -0
- package/src/react.ts +27 -0
- package/src/replay.ts +13 -0
- package/src/runtime/api.ts +531 -0
- package/src/runtime/attachment-model.ts +42 -0
- package/src/runtime/attachments.tsx +1260 -0
- package/src/runtime/boot.ts +95 -0
- package/src/runtime/browser-label.ts +31 -0
- package/src/runtime/build-id.ts +10 -0
- package/src/runtime/capture-ui.tsx +306 -0
- package/src/runtime/capture.ts +508 -0
- package/src/runtime/clarity-model.ts +634 -0
- package/src/runtime/clarity-ui.tsx +454 -0
- package/src/runtime/client.ts +151 -0
- package/src/runtime/composer-size.tsx +146 -0
- package/src/runtime/dashboard.tsx +1130 -0
- package/src/runtime/debug-context-core.ts +681 -0
- package/src/runtime/debug-context-host.ts +56 -0
- package/src/runtime/debug-context-model.ts +1319 -0
- package/src/runtime/debug-context-redact.ts +259 -0
- package/src/runtime/debug-context.ts +613 -0
- package/src/runtime/details-model.ts +30 -0
- package/src/runtime/details.tsx +395 -0
- package/src/runtime/dictation-engine.ts +616 -0
- package/src/runtime/dictation-model.ts +79 -0
- package/src/runtime/dictation-ui.tsx +363 -0
- package/src/runtime/dictation.ts +271 -0
- package/src/runtime/dock.ts +108 -0
- package/src/runtime/draft-dock.tsx +124 -0
- package/src/runtime/draft-model.ts +251 -0
- package/src/runtime/draft-store.ts +149 -0
- package/src/runtime/edits.tsx +165 -0
- package/src/runtime/font.ts +42 -0
- package/src/runtime/format.ts +129 -0
- package/src/runtime/freshness.ts +23 -0
- package/src/runtime/handoff.ts +76 -0
- package/src/runtime/host-watch.ts +64 -0
- package/src/runtime/icons.tsx +295 -0
- package/src/runtime/idle.ts +20 -0
- package/src/runtime/index.ts +11 -0
- package/src/runtime/ink.ts +6 -0
- package/src/runtime/kind-model.ts +20 -0
- package/src/runtime/live-model.ts +32 -0
- package/src/runtime/loader.tsx +87 -0
- package/src/runtime/markup-editor.tsx +1333 -0
- package/src/runtime/markup-model.ts +485 -0
- package/src/runtime/markup.ts +382 -0
- package/src/runtime/members.ts +58 -0
- package/src/runtime/mention-parse.ts +56 -0
- package/src/runtime/mentions.tsx +205 -0
- package/src/runtime/page-background.ts +51 -0
- package/src/runtime/page-image.ts +1592 -0
- package/src/runtime/placement.ts +336 -0
- package/src/runtime/record.ts +402 -0
- package/src/runtime/redact.ts +30 -0
- package/src/runtime/related.ts +34 -0
- package/src/runtime/render-page.ts +2 -0
- package/src/runtime/replay-model.ts +232 -0
- package/src/runtime/replay.ts +173 -0
- package/src/runtime/runtime-version.ts +7 -0
- package/src/runtime/runtime.tsx +51 -0
- package/src/runtime/screen.ts +182 -0
- package/src/runtime/screenshot.ts +260 -0
- package/src/runtime/session-source.ts +112 -0
- package/src/runtime/session.ts +108 -0
- package/src/runtime/shield.ts +34 -0
- package/src/runtime/shot.tsx +422 -0
- package/src/runtime/sketch.ts +139 -0
- package/src/runtime/styles.ts +1541 -0
- package/src/runtime/sync.ts +300 -0
- package/src/runtime/thread-model.ts +71 -0
- package/src/runtime/ticket-form-model.ts +128 -0
- package/src/runtime/ticket-form.tsx +342 -0
- package/src/runtime/toolbar.tsx +4427 -0
- package/src/runtime/ui-scale.ts +75 -0
- package/src/runtime/update.ts +98 -0
- package/src/runtime/use-debug-context.ts +81 -0
- package/src/server/index.ts +133 -0
- package/src/server/support.ts +499 -0
- package/src/server.ts +4 -0
- package/src/vite/plugin.ts +102 -0
- package/src/vite.ts +1 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a host page brings the toolbar up: wait for an idle moment, ask the session route, import the runtime
|
|
3
|
+
* from the service the session names, mount it at the end of `<body>`. `<CommentToolbar>` (`./loader.tsx`, the
|
|
4
|
+
* npm package) and the script-tag loader the service serves (`../embed`, `/v1/toolbar/embed.js`) both run this
|
|
5
|
+
* and nothing else, so the two cannot drift.
|
|
6
|
+
*
|
|
7
|
+
* No React and no toolbar code is imported here (types only): the script-tag loader is this file and a few
|
|
8
|
+
* small ones, a few kilobytes in all.
|
|
9
|
+
*/
|
|
10
|
+
import { whenIdle } from "./idle.js";
|
|
11
|
+
import type { RuntimeProps, ToolbarHandle } from "./runtime.js";
|
|
12
|
+
import { runtimeVersion } from "./runtime-version.js";
|
|
13
|
+
import type { Session } from "./session-source.js";
|
|
14
|
+
|
|
15
|
+
export type Runtime = { mount(container: HTMLElement, props: RuntimeProps): ToolbarHandle };
|
|
16
|
+
|
|
17
|
+
/** The session request waits for the page to go idle, but no longer than this after the loader starts. */
|
|
18
|
+
export const IDLE_TIMEOUT_MS = 3000;
|
|
19
|
+
|
|
20
|
+
/** Runs `task` later and returns a function that cancels it. */
|
|
21
|
+
export type Wait = (task: () => void) => () => void;
|
|
22
|
+
|
|
23
|
+
const untilIdle: Wait = (task) => whenIdle(task, IDLE_TIMEOUT_MS);
|
|
24
|
+
|
|
25
|
+
const importModule = (url: string): Promise<unknown> =>
|
|
26
|
+
import(/* @vite-ignore */ /* webpackIgnore: true */ url);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The toolbar always comes from the App comments worker, never from the host's own bundle: every deploy of the
|
|
30
|
+
* service serves the new build, open toolbars swap to it on their own, and a host never needs an update for it.
|
|
31
|
+
*/
|
|
32
|
+
export async function loadRuntime(apiUrl: string, load = importModule): Promise<Runtime> {
|
|
33
|
+
const url = `${apiUrl.replace(/\/+$/, "")}/v1/toolbar/${runtimeVersion}.js`;
|
|
34
|
+
const remote = (await load(url)) as Partial<Runtime>;
|
|
35
|
+
if (typeof remote.mount === "function") return remote as Runtime;
|
|
36
|
+
throw new Error(`${url} has no mount export`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type Boot = {
|
|
40
|
+
/** Asks the session route, or reuses its kept answer. Null, or a rejection, means no toolbar. */
|
|
41
|
+
getSession: () => Promise<Session | null>;
|
|
42
|
+
/** Read when the toolbar mounts, so it starts with the host's latest props. */
|
|
43
|
+
props: () => RuntimeProps;
|
|
44
|
+
/** Called once the toolbar is mounted, with its handle and the element it is mounted in. */
|
|
45
|
+
onMount?: (handle: ToolbarHandle, container: HTMLElement) => void;
|
|
46
|
+
/** Called when this attempt is over: the toolbar is mounted, or it will not be. */
|
|
47
|
+
onSettled?: () => void;
|
|
48
|
+
/** When to start. Default: when the page is idle, or after IDLE_TIMEOUT_MS at the latest. */
|
|
49
|
+
wait?: Wait;
|
|
50
|
+
/** Where the runtime comes from. Default: `loadRuntime`. */
|
|
51
|
+
load?: (apiUrl: string) => Promise<Runtime>;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One attempt to bring the toolbar up. Does nothing until `wait` runs it; then asks for the session once, and
|
|
56
|
+
* only when comments are on loads the runtime and mounts it in a new `<div data-cs-runtime="latest">` at the
|
|
57
|
+
* end of `<body>`.
|
|
58
|
+
*
|
|
59
|
+
* Returns `stop`: before the mount it calls the attempt off, after it it unmounts the toolbar and removes the
|
|
60
|
+
* element.
|
|
61
|
+
*/
|
|
62
|
+
export function bootToolbar(boot: Boot): () => void {
|
|
63
|
+
let cancelled = false;
|
|
64
|
+
let mounted: ToolbarHandle | null = null;
|
|
65
|
+
let container: HTMLElement | null = null;
|
|
66
|
+
const start = async () => {
|
|
67
|
+
const session = await boot.getSession().catch(() => null);
|
|
68
|
+
if (!session || cancelled) return;
|
|
69
|
+
const runtime = await (boot.load ?? loadRuntime)(session.apiUrl);
|
|
70
|
+
if (cancelled) return;
|
|
71
|
+
container = document.createElement("div");
|
|
72
|
+
container.setAttribute("data-cs-runtime", "latest");
|
|
73
|
+
container.style.display = "contents";
|
|
74
|
+
// appendChild, not append: the Workers types (a workspace may type-check this source with
|
|
75
|
+
// them) replace `Element.append` with HTMLRewriter's string/stream signature.
|
|
76
|
+
document.body.appendChild(container);
|
|
77
|
+
mounted = runtime.mount(container, boot.props());
|
|
78
|
+
boot.onMount?.(mounted, container);
|
|
79
|
+
};
|
|
80
|
+
const cancelWait = (boot.wait ?? untilIdle)(() => {
|
|
81
|
+
start()
|
|
82
|
+
.catch((error) => console.warn("[comment-service] The toolbar could not start.", error))
|
|
83
|
+
.finally(() => boot.onSettled?.());
|
|
84
|
+
});
|
|
85
|
+
return () => {
|
|
86
|
+
cancelled = true;
|
|
87
|
+
cancelWait();
|
|
88
|
+
// In a microtask: a host that stops the toolbar from inside its own React commit (an effect cleanup) must
|
|
89
|
+
// not unmount the toolbar's root while React is still rendering.
|
|
90
|
+
queueMicrotask(() => {
|
|
91
|
+
mounted?.unmount();
|
|
92
|
+
container?.remove();
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
const BROWSERS: Array<[string, RegExp]> = [
|
|
2
|
+
["Edge", /Edg\/(\d+)/],
|
|
3
|
+
["Firefox", /Firefox\/(\d+)/],
|
|
4
|
+
["Chrome", /Chrome\/(\d+)/],
|
|
5
|
+
["Safari", /Version\/(\d+)[\d.]* .*Safari/],
|
|
6
|
+
];
|
|
7
|
+
const SYSTEMS: Array<[string, RegExp]> = [
|
|
8
|
+
["iOS", /iPhone|iPad/],
|
|
9
|
+
["Android", /Android/],
|
|
10
|
+
["macOS", /Mac OS X/],
|
|
11
|
+
["Windows", /Windows/],
|
|
12
|
+
["Linux", /Linux/],
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
/** "Chrome 142" from a user agent, or undefined. Only the name and major version: never the full string. */
|
|
16
|
+
export function browserName(agent: string): string | undefined {
|
|
17
|
+
for (const [name, pattern] of BROWSERS) {
|
|
18
|
+
const version = agent.match(pattern)?.[1];
|
|
19
|
+
if (version) return `${name} ${version}`;
|
|
20
|
+
}
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function systemName(agent: string): string | undefined {
|
|
25
|
+
return SYSTEMS.find(([, pattern]) => pattern.test(agent))?.[0];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** "Chrome 142 · macOS", or undefined when neither is known. */
|
|
29
|
+
export function browserLabel(agent: string): string | undefined {
|
|
30
|
+
return [browserName(agent), systemName(agent)].filter(Boolean).join(" · ") || undefined;
|
|
31
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
declare const __CS_BUILD__: string | undefined;
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Identifies this build of the runtime. `scripts/app-comments/build-toolbar.mjs` defines it from a hash of the
|
|
5
|
+
* runtime's source and writes the same value to `/v1/toolbar/build.json` next to the bundle, so a running toolbar
|
|
6
|
+
* can tell when the worker started serving a newer build. "dev" when the bundle was built without it (tests).
|
|
7
|
+
* Any change under src/runtime or src/core changes the id, so every deploy that touches the toolbar is announced.
|
|
8
|
+
*/
|
|
9
|
+
export const runtimeBuild: string =
|
|
10
|
+
typeof __CS_BUILD__ === "string" && __CS_BUILD__ ? __CS_BUILD__ : "dev";
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import type { Capture } from "./client.js";
|
|
3
|
+
import { describeCapture } from "./screenshot.js";
|
|
4
|
+
import { redactSecretSearchParams } from "./replay.js";
|
|
5
|
+
import {
|
|
6
|
+
formatClock,
|
|
7
|
+
RECORD_MAX_MS,
|
|
8
|
+
startTabRecording,
|
|
9
|
+
toggleMicrophone,
|
|
10
|
+
type TabTake,
|
|
11
|
+
} from "./record.js";
|
|
12
|
+
import * as Icon from "./icons.js";
|
|
13
|
+
|
|
14
|
+
const MAX_BYTES = 10 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
type AddFiles = (files: Array<{ blob: Blob; name: string; capture?: Capture }>) => void;
|
|
17
|
+
|
|
18
|
+
/** What a take tells the world while it runs, and what goes with the file when it is kept. */
|
|
19
|
+
export type RecordingExtras = {
|
|
20
|
+
/** Every chunk as it lands (see `RecordOptions.onChunk`). */
|
|
21
|
+
onChunk?: (chunk: Blob, index: number, mime: string) => void;
|
|
22
|
+
/** Files to attach beside the recording, named after the same stamp (a replay of what happened in it). */
|
|
23
|
+
companions?: (info: {
|
|
24
|
+
stamp: string;
|
|
25
|
+
startedAt: number;
|
|
26
|
+
durationMs: number;
|
|
27
|
+
name: string;
|
|
28
|
+
}) => Array<{ blob: Blob; name: string }>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function pageUrl() {
|
|
32
|
+
const url = new URL(window.location.href);
|
|
33
|
+
url.searchParams.delete("cs-thread");
|
|
34
|
+
url.searchParams.delete("cs-invite");
|
|
35
|
+
redactSecretSearchParams(url);
|
|
36
|
+
return `${url.origin}${url.pathname}${url.search}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function stamp() {
|
|
40
|
+
return new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The clock turns into a warning this close to the limit. */
|
|
44
|
+
const ENDING_MS = 10_000;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The recording's sound: `on` and `muted` while the mic is in the take, `off` when the person chose no sound,
|
|
48
|
+
* `refused` when they wanted it but the mic wasn't given.
|
|
49
|
+
*/
|
|
50
|
+
export type RecordingSound = "on" | "muted" | "off" | "refused";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Records the current tab and attaches the file to the draft as soon as it stops. Nothing is uploaded here. `sound` is the
|
|
54
|
+
* person's saved choice to record the mic; `onSoundChange` saves a new one.
|
|
55
|
+
*/
|
|
56
|
+
export function useTabRecording(
|
|
57
|
+
onError: (message: string) => void,
|
|
58
|
+
sound: boolean,
|
|
59
|
+
onSoundChange: (sound: boolean) => void,
|
|
60
|
+
) {
|
|
61
|
+
const [phase, setPhase] = useState<"idle" | "recording">("idle");
|
|
62
|
+
const [soundState, setSoundState] = useState<RecordingSound>("off");
|
|
63
|
+
const [startedAt, setStartedAt] = useState(0);
|
|
64
|
+
const [now, setNow] = useState(0);
|
|
65
|
+
const take = useRef<TabTake | null>(null);
|
|
66
|
+
const addFiles = useRef<AddFiles | null>(null);
|
|
67
|
+
const extras = useRef<RecordingExtras | null>(null);
|
|
68
|
+
const capture = useRef<Capture | null>(null);
|
|
69
|
+
const alive = useRef(true);
|
|
70
|
+
const generation = useRef(0);
|
|
71
|
+
const phaseRef = useRef(phase);
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
phaseRef.current = phase;
|
|
74
|
+
}, [phase]);
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
alive.current = true;
|
|
78
|
+
return () => {
|
|
79
|
+
alive.current = false;
|
|
80
|
+
generation.current += 1;
|
|
81
|
+
take.current?.cancel();
|
|
82
|
+
};
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
useEffect(() => {
|
|
86
|
+
if (phase !== "recording") return;
|
|
87
|
+
const id = window.setInterval(() => setNow(Date.now()), 250);
|
|
88
|
+
return () => window.clearInterval(id);
|
|
89
|
+
}, [phase]);
|
|
90
|
+
|
|
91
|
+
/** Attaches a finished take, with its companions, to whatever the recording was started for. */
|
|
92
|
+
const attach = useCallback((blob: Blob, startedAt: number, stoppedAt: number) => {
|
|
93
|
+
const extension = blob.type === "video/mp4" ? "mp4" : "webm";
|
|
94
|
+
const when = stamp();
|
|
95
|
+
const name = `recording-${when}.${extension}`;
|
|
96
|
+
const companions =
|
|
97
|
+
extras.current?.companions?.({
|
|
98
|
+
stamp: when,
|
|
99
|
+
startedAt,
|
|
100
|
+
durationMs: Math.max(1000, stoppedAt - startedAt),
|
|
101
|
+
name,
|
|
102
|
+
}) ?? [];
|
|
103
|
+
addFiles.current?.([{ blob, name, capture: capture.current ?? undefined }, ...companions]);
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
const begin = useCallback(
|
|
107
|
+
async (add: AddFiles, more?: RecordingExtras) => {
|
|
108
|
+
if (take.current || phaseRef.current === "recording") return;
|
|
109
|
+
addFiles.current = add;
|
|
110
|
+
extras.current = more ?? null;
|
|
111
|
+
const gen = ++generation.current;
|
|
112
|
+
phaseRef.current = "recording";
|
|
113
|
+
try {
|
|
114
|
+
const onChunk = more?.onChunk;
|
|
115
|
+
const next = await startTabRecording({ sound, onChunk }, (ready) => {
|
|
116
|
+
if (!alive.current || generation.current !== gen) {
|
|
117
|
+
ready.cancel();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
take.current = ready;
|
|
121
|
+
});
|
|
122
|
+
if (!alive.current || generation.current !== gen) {
|
|
123
|
+
next.cancel();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
take.current = next;
|
|
127
|
+
setSoundState(next.microphone ? "on" : sound ? "refused" : "off");
|
|
128
|
+
capture.current = describeCapture(pageUrl());
|
|
129
|
+
const started = Date.now();
|
|
130
|
+
setStartedAt(started);
|
|
131
|
+
setNow(started);
|
|
132
|
+
setPhase("recording");
|
|
133
|
+
void next.finished.then((blob) => {
|
|
134
|
+
take.current = null;
|
|
135
|
+
const stopped = Date.now();
|
|
136
|
+
if (!alive.current || generation.current !== gen) return;
|
|
137
|
+
phaseRef.current = "idle";
|
|
138
|
+
setPhase("idle");
|
|
139
|
+
if (!blob) return;
|
|
140
|
+
if (blob.size > MAX_BYTES) {
|
|
141
|
+
onError("That recording is over 10 MB. Record a shorter one.");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
attach(blob, started, stopped);
|
|
145
|
+
});
|
|
146
|
+
} catch (caught) {
|
|
147
|
+
if (generation.current === gen) phaseRef.current = "idle";
|
|
148
|
+
if (alive.current && generation.current === gen)
|
|
149
|
+
onError(caught instanceof Error ? caught.message : "Couldn’t start a recording.");
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
[attach, onError, sound],
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const stop = useCallback(() => take.current?.stop(), []);
|
|
156
|
+
|
|
157
|
+
/** Mutes or unmutes the mic in this take, and remembers the choice for the next one. */
|
|
158
|
+
const toggleSound = useCallback(() => {
|
|
159
|
+
const microphone = take.current?.microphone;
|
|
160
|
+
if (microphone) {
|
|
161
|
+
const enabled = toggleMicrophone(microphone);
|
|
162
|
+
setSoundState(enabled ? "on" : "muted");
|
|
163
|
+
onSoundChange(enabled);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// No mic in this take: the choice applies from the next recording.
|
|
167
|
+
onSoundChange(!sound);
|
|
168
|
+
}, [onSoundChange, sound]);
|
|
169
|
+
|
|
170
|
+
const cancel = useCallback(() => {
|
|
171
|
+
generation.current += 1;
|
|
172
|
+
take.current?.cancel();
|
|
173
|
+
take.current = null;
|
|
174
|
+
phaseRef.current = "idle";
|
|
175
|
+
setPhase("idle");
|
|
176
|
+
}, []);
|
|
177
|
+
|
|
178
|
+
return {
|
|
179
|
+
phase,
|
|
180
|
+
elapsed: phase === "recording" ? Math.max(0, now - startedAt) : 0,
|
|
181
|
+
sound: soundState,
|
|
182
|
+
/** What the next recording will do about sound. */
|
|
183
|
+
soundNext: sound,
|
|
184
|
+
toggleSound,
|
|
185
|
+
begin,
|
|
186
|
+
stop,
|
|
187
|
+
cancel,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Dictation into the comment the recording belongs to, driven from the record bar. */
|
|
192
|
+
export type RecordDictation = {
|
|
193
|
+
listening: boolean;
|
|
194
|
+
/** Pieces still being written. */
|
|
195
|
+
queued: number;
|
|
196
|
+
toggle: () => void;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
function soundLabel(sound: RecordingSound, soundNext: boolean) {
|
|
200
|
+
if (sound === "on")
|
|
201
|
+
return {
|
|
202
|
+
text: "Mic on",
|
|
203
|
+
tip: "Your voice is in the recording. Click to mute.",
|
|
204
|
+
};
|
|
205
|
+
if (sound === "muted") return { text: "Mic muted", tip: "Click to put your voice back in." };
|
|
206
|
+
if (sound === "refused")
|
|
207
|
+
return {
|
|
208
|
+
text: "No mic",
|
|
209
|
+
tip: "The microphone wasn’t allowed, so this recording has no sound.",
|
|
210
|
+
};
|
|
211
|
+
return soundNext
|
|
212
|
+
? {
|
|
213
|
+
text: "Mic next time",
|
|
214
|
+
tip: "This recording has no sound. The next one will.",
|
|
215
|
+
}
|
|
216
|
+
: {
|
|
217
|
+
text: "Mic off",
|
|
218
|
+
tip: "This recording has no sound. Click to record your voice next time.",
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function RecordChrome({
|
|
223
|
+
elapsed,
|
|
224
|
+
sound,
|
|
225
|
+
soundNext,
|
|
226
|
+
dictation,
|
|
227
|
+
onToggleSound,
|
|
228
|
+
onStop,
|
|
229
|
+
onCancel,
|
|
230
|
+
}: {
|
|
231
|
+
elapsed: number;
|
|
232
|
+
sound: RecordingSound;
|
|
233
|
+
soundNext: boolean;
|
|
234
|
+
/** Absent when there is no comment box to type into. */
|
|
235
|
+
dictation?: RecordDictation;
|
|
236
|
+
onToggleSound: () => void;
|
|
237
|
+
onStop: () => void;
|
|
238
|
+
onCancel: () => void;
|
|
239
|
+
}) {
|
|
240
|
+
const left = RECORD_MAX_MS - elapsed;
|
|
241
|
+
const ending = left <= ENDING_MS;
|
|
242
|
+
const mic = soundLabel(sound, soundNext);
|
|
243
|
+
const talking = sound === "on" || dictation?.listening;
|
|
244
|
+
return (
|
|
245
|
+
<div className="cs-record cs-hit" data-cs-ui="record" role="status">
|
|
246
|
+
<span className="cs-record-dot" />
|
|
247
|
+
<span
|
|
248
|
+
className={`cs-record-time${ending ? " is-ending" : ""}`}
|
|
249
|
+
title={`Recordings stop by themselves at ${formatClock(RECORD_MAX_MS)}`}
|
|
250
|
+
>
|
|
251
|
+
{ending
|
|
252
|
+
? `Stops in ${formatClock(left)}`
|
|
253
|
+
: `${formatClock(elapsed)} / ${formatClock(RECORD_MAX_MS)}`}
|
|
254
|
+
</span>
|
|
255
|
+
<button
|
|
256
|
+
className={`cs-button cs-record-toggle${sound === "on" ? " is-on" : ""}`}
|
|
257
|
+
data-cs-ui="record-sound"
|
|
258
|
+
data-cs-sound={sound}
|
|
259
|
+
type="button"
|
|
260
|
+
aria-pressed={sound === "on"}
|
|
261
|
+
title={mic.tip}
|
|
262
|
+
disabled={sound === "refused"}
|
|
263
|
+
onClick={onToggleSound}
|
|
264
|
+
>
|
|
265
|
+
<Icon.Mic />
|
|
266
|
+
{mic.text}
|
|
267
|
+
</button>
|
|
268
|
+
{dictation ? (
|
|
269
|
+
<button
|
|
270
|
+
className={`cs-button cs-record-toggle${dictation.listening ? " is-on" : ""}`}
|
|
271
|
+
data-cs-ui="record-dictate"
|
|
272
|
+
type="button"
|
|
273
|
+
aria-pressed={dictation.listening}
|
|
274
|
+
title={
|
|
275
|
+
dictation.listening
|
|
276
|
+
? "Stop typing what you say"
|
|
277
|
+
: "Also type what you say into your comment"
|
|
278
|
+
}
|
|
279
|
+
onClick={dictation.toggle}
|
|
280
|
+
>
|
|
281
|
+
{dictation.listening
|
|
282
|
+
? "Typing what you say…"
|
|
283
|
+
: dictation.queued > 0
|
|
284
|
+
? "Writing…"
|
|
285
|
+
: "Type what I say"}
|
|
286
|
+
</button>
|
|
287
|
+
) : null}
|
|
288
|
+
<span className="cs-record-note">
|
|
289
|
+
{talking
|
|
290
|
+
? "Show and explain what went wrong, then stop."
|
|
291
|
+
: "Show what went wrong, then stop."}
|
|
292
|
+
</span>
|
|
293
|
+
<button
|
|
294
|
+
className="cs-button is-danger"
|
|
295
|
+
data-cs-ui="record-stop"
|
|
296
|
+
type="button"
|
|
297
|
+
onClick={onStop}
|
|
298
|
+
>
|
|
299
|
+
Stop
|
|
300
|
+
</button>
|
|
301
|
+
<button className="cs-button" data-cs-ui="record-cancel" type="button" onClick={onCancel}>
|
|
302
|
+
Cancel
|
|
303
|
+
</button>
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
}
|