@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.
- package/README.md +152 -18
- package/dist/react.js +516 -61
- 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 +8 -1
- 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 +7 -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 +3 -1
- 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 -11
- 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 +123 -0
- 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/runtime.d.ts +6 -0
- package/dist/types/runtime/screen.d.ts +47 -0
- package/dist/types/runtime/screenshot.d.ts +60 -6
- 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 +37 -0
- 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 +26 -13
- 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
- package/dist/chunks/chunk-CSKXLF33.js +0 -153
- package/dist/chunks/chunk-HUM5FQII.js +0 -160
- package/dist/chunks/render-page-76MLIT6M.js +0 -1421
- package/dist/chunks/runtime-JX75TKER.js +0 -9267
- package/dist/chunks/screenshot-66ARTBGR.js +0 -12
|
@@ -0,0 +1,4427 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { Anchor, Sketch } from "../core/index.js";
|
|
4
|
+
import {
|
|
5
|
+
useCallback,
|
|
6
|
+
useEffect,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
useSyncExternalStore,
|
|
12
|
+
type ButtonHTMLAttributes,
|
|
13
|
+
type CSSProperties,
|
|
14
|
+
type HTMLAttributes,
|
|
15
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
16
|
+
type PointerEvent as ReactPointerEvent,
|
|
17
|
+
type ReactNode,
|
|
18
|
+
} from "react";
|
|
19
|
+
import { createPortal } from "react-dom";
|
|
20
|
+
import { captureAnchor, describeElement, elementAt, resolveInDocument } from "./capture.js";
|
|
21
|
+
import {
|
|
22
|
+
adoptStoredMember,
|
|
23
|
+
finishMsfSignIn,
|
|
24
|
+
listenForMsfHandoff,
|
|
25
|
+
msfAuthorization,
|
|
26
|
+
relayMsfHandoff,
|
|
27
|
+
signInOptions,
|
|
28
|
+
type Access,
|
|
29
|
+
type SignInOptions,
|
|
30
|
+
createThread,
|
|
31
|
+
editMessage,
|
|
32
|
+
fetchTicketForm,
|
|
33
|
+
getProfile,
|
|
34
|
+
memberIsCurrent,
|
|
35
|
+
replyToThread,
|
|
36
|
+
saveSettings,
|
|
37
|
+
storageKey,
|
|
38
|
+
updateProfile,
|
|
39
|
+
updateThread,
|
|
40
|
+
uploadAttachment,
|
|
41
|
+
type Capture,
|
|
42
|
+
type CommentMessage,
|
|
43
|
+
type Delivery,
|
|
44
|
+
type Details,
|
|
45
|
+
type Member,
|
|
46
|
+
type Profile,
|
|
47
|
+
type Settings,
|
|
48
|
+
type Thread,
|
|
49
|
+
transcribeAudio,
|
|
50
|
+
} from "./client.js";
|
|
51
|
+
import {
|
|
52
|
+
DetailsEditor,
|
|
53
|
+
DetailsInput,
|
|
54
|
+
FieldValues,
|
|
55
|
+
KindInput,
|
|
56
|
+
RoleBadge,
|
|
57
|
+
TicketBadge,
|
|
58
|
+
SettingsPanel,
|
|
59
|
+
detailsEnabled,
|
|
60
|
+
detailsPayload,
|
|
61
|
+
hasFieldValues,
|
|
62
|
+
noDetails,
|
|
63
|
+
} from "./details.js";
|
|
64
|
+
import {
|
|
65
|
+
TicketFormInput,
|
|
66
|
+
missingTicketFields,
|
|
67
|
+
ticketDraftPayload,
|
|
68
|
+
type TicketForm,
|
|
69
|
+
type TicketValues,
|
|
70
|
+
} from "./ticket-form.js";
|
|
71
|
+
import {
|
|
72
|
+
ago,
|
|
73
|
+
colorFor,
|
|
74
|
+
deliveryText,
|
|
75
|
+
initials,
|
|
76
|
+
openPinCardSpot,
|
|
77
|
+
paintThreadStatuses,
|
|
78
|
+
statusNote,
|
|
79
|
+
targetName,
|
|
80
|
+
} from "./format.js";
|
|
81
|
+
import * as Icon from "./icons.js";
|
|
82
|
+
import {
|
|
83
|
+
AttachButtons,
|
|
84
|
+
AttachmentTray,
|
|
85
|
+
MediaPane,
|
|
86
|
+
MessageAttachments,
|
|
87
|
+
filesFromClipboard,
|
|
88
|
+
pendingPreviews,
|
|
89
|
+
useAttachments,
|
|
90
|
+
visualAttachments,
|
|
91
|
+
} from "./attachments.js";
|
|
92
|
+
import { DraftDock, draftDockMode } from "./draft-dock.js";
|
|
93
|
+
import {
|
|
94
|
+
createDraftStore,
|
|
95
|
+
describeDraft,
|
|
96
|
+
recoverRecording,
|
|
97
|
+
type StoredDraft,
|
|
98
|
+
} from "./draft-store.js";
|
|
99
|
+
import type { MarkupShape } from "./markup.js";
|
|
100
|
+
import { ShotSurface, flattenCapture } from "./shot.js";
|
|
101
|
+
import { uiUnit, useScreenTier } from "./ui-scale.js";
|
|
102
|
+
import { RecordChrome, useTabRecording } from "./capture-ui.js";
|
|
103
|
+
import { CLARITY_WORDS, readClarityMode, type ClarityMode } from "./clarity-model.js";
|
|
104
|
+
import type { ReportKind } from "./kind-model.js";
|
|
105
|
+
import { ClarityQuestions, useClarity } from "./clarity-ui.js";
|
|
106
|
+
import { DictateButton, isDictationKey, useDictation } from "./dictation-ui.js";
|
|
107
|
+
import { ResizeGrip, readComposerSize, type ComposerSize } from "./composer-size.js";
|
|
108
|
+
import { RECORD_MAX_MS } from "./record.js";
|
|
109
|
+
import { ReplayBuffer, redactSecretSearchParams, watchActivity } from "./replay.js";
|
|
110
|
+
import type { DebugContextConfig } from "./debug-context-model.js";
|
|
111
|
+
import { uploadDebugContext, useDebugContext } from "./use-debug-context.js";
|
|
112
|
+
import { MentionTextarea, MessageText, mentionsIn } from "./mentions.js";
|
|
113
|
+
import { useMembers } from "./members.js";
|
|
114
|
+
import {
|
|
115
|
+
clampBar,
|
|
116
|
+
dockMargin,
|
|
117
|
+
dockSlop,
|
|
118
|
+
placeDock,
|
|
119
|
+
readDock,
|
|
120
|
+
snapDock,
|
|
121
|
+
type Dock,
|
|
122
|
+
type DockPoint,
|
|
123
|
+
} from "./dock.js";
|
|
124
|
+
import {
|
|
125
|
+
overlayBounds,
|
|
126
|
+
placeAgainst,
|
|
127
|
+
useOverlayHost,
|
|
128
|
+
usePlacement,
|
|
129
|
+
type Box,
|
|
130
|
+
type Placed,
|
|
131
|
+
} from "./placement.js";
|
|
132
|
+
import {
|
|
133
|
+
captureAround,
|
|
134
|
+
describeCapture,
|
|
135
|
+
shotAround,
|
|
136
|
+
shotPage,
|
|
137
|
+
takeShot,
|
|
138
|
+
type PageShot,
|
|
139
|
+
} from "./screenshot.js";
|
|
140
|
+
import { prewarmPageImage } from "./page-image.js";
|
|
141
|
+
import {
|
|
142
|
+
INK,
|
|
143
|
+
INK_HALO,
|
|
144
|
+
elementUnder,
|
|
145
|
+
inkBox,
|
|
146
|
+
isTap,
|
|
147
|
+
pathData,
|
|
148
|
+
simplify,
|
|
149
|
+
sketchFor,
|
|
150
|
+
type Point,
|
|
151
|
+
} from "./sketch.js";
|
|
152
|
+
import { whenIdle } from "./idle.js";
|
|
153
|
+
import { CHECK_EVERY_MS, needsSwap, onBuildAnnounced, servedBuild, swapRuntime } from "./update.js";
|
|
154
|
+
import { announceNavigation, NAVIGATED, watchHost } from "./host-watch.js";
|
|
155
|
+
import { isBarePath, screenAt, type Here, type ScreenRule } from "./screen.js";
|
|
156
|
+
import {
|
|
157
|
+
askHost,
|
|
158
|
+
forgetSignIns,
|
|
159
|
+
readJwt,
|
|
160
|
+
sessionFor,
|
|
161
|
+
type GetSession,
|
|
162
|
+
type Session,
|
|
163
|
+
} from "./session.js";
|
|
164
|
+
import { shieldFromPage } from "./shield.js";
|
|
165
|
+
import { useThreadsSync, type ThreadsPayload } from "./sync.js";
|
|
166
|
+
import { Dashboard, type DashboardFocus, type DashboardScreen } from "./dashboard.js";
|
|
167
|
+
import { MessageEditor, MessageHistory, MessageTools, isMine } from "./edits.js";
|
|
168
|
+
import {
|
|
169
|
+
captureHandoff,
|
|
170
|
+
dropHandoff,
|
|
171
|
+
linkWith,
|
|
172
|
+
readHandoff,
|
|
173
|
+
stripHandoffFromUrl,
|
|
174
|
+
} from "./handoff.js";
|
|
175
|
+
import { loadFont } from "./font.js";
|
|
176
|
+
import { injectStyles } from "./styles.js";
|
|
177
|
+
|
|
178
|
+
/** The page screenshot for a new comment or reply starts when the browser is idle, or this long after at the latest. */
|
|
179
|
+
const SNAPSHOT_DELAY_MS = 500;
|
|
180
|
+
/** Files the service keeps on one message (`MAX_PER_MESSAGE`). */
|
|
181
|
+
const MAX_FILES_PER_MESSAGE = 5;
|
|
182
|
+
/** How far back the step trail reaches: the debug context's steps. */
|
|
183
|
+
const DEBUG_STEPS_MS = 180_000;
|
|
184
|
+
/** What "Replay" attaches: the last minute. */
|
|
185
|
+
const INSTANT_REPLAY_MS = 60_000;
|
|
186
|
+
/** Fonts and stylesheet assets are embedded ahead of the first screenshot, once the toolbar has settled. */
|
|
187
|
+
const PREWARM_DELAY_MS = 4000;
|
|
188
|
+
|
|
189
|
+
export type ToolbarPosition = "bottom-left" | "bottom-center" | "bottom-right";
|
|
190
|
+
export type ToolbarTheme = "light" | "dark" | "system";
|
|
191
|
+
type Mode = "browse" | "select" | "draw" | "reattach";
|
|
192
|
+
type Phase = "boot" | "ready" | "off";
|
|
193
|
+
type Surface = "none" | "unlock" | "dashboard";
|
|
194
|
+
type Prefs = {
|
|
195
|
+
pinsHidden?: boolean;
|
|
196
|
+
position?: ToolbarPosition;
|
|
197
|
+
/** Edge the bar snapped to after a drag. Overrides `position` until a preset is chosen. */
|
|
198
|
+
dock?: Dock;
|
|
199
|
+
/** The composer's size, once someone resized it. */
|
|
200
|
+
composerSize?: ComposerSize;
|
|
201
|
+
/** Recordings carry the mic unless this is false. */
|
|
202
|
+
recordSound?: boolean;
|
|
203
|
+
/** Dictation also attaches the take as a voice file unless this is false. */
|
|
204
|
+
keepVoice?: boolean;
|
|
205
|
+
};
|
|
206
|
+
type BarFrame = {
|
|
207
|
+
edge: Dock["edge"];
|
|
208
|
+
side: "start" | "end";
|
|
209
|
+
x: number;
|
|
210
|
+
y: number;
|
|
211
|
+
width: number;
|
|
212
|
+
height: number;
|
|
213
|
+
};
|
|
214
|
+
captureHandoff();
|
|
215
|
+
|
|
216
|
+
const prefsKey = "devaccel.comment.prefs";
|
|
217
|
+
const positions: Array<{ value: ToolbarPosition; label: string }> = [
|
|
218
|
+
{ value: "bottom-left", label: "Left" },
|
|
219
|
+
{ value: "bottom-center", label: "Center" },
|
|
220
|
+
{ value: "bottom-right", label: "Right" },
|
|
221
|
+
];
|
|
222
|
+
|
|
223
|
+
function readPrefs(): Prefs {
|
|
224
|
+
try {
|
|
225
|
+
const parsed = JSON.parse(localStorage.getItem(prefsKey) ?? "{}") as Prefs & {
|
|
226
|
+
sheet?: unknown;
|
|
227
|
+
sheetMinH?: unknown;
|
|
228
|
+
};
|
|
229
|
+
// The comments side panel's saved box, from before the panel was replaced by the dashboard.
|
|
230
|
+
delete parsed.sheet;
|
|
231
|
+
delete parsed.sheetMinH;
|
|
232
|
+
const dock = readDock(parsed.dock);
|
|
233
|
+
if (dock) parsed.dock = dock;
|
|
234
|
+
else delete parsed.dock;
|
|
235
|
+
const composerSize = readComposerSize(parsed.composerSize);
|
|
236
|
+
if (composerSize) parsed.composerSize = composerSize;
|
|
237
|
+
else delete parsed.composerSize;
|
|
238
|
+
return parsed;
|
|
239
|
+
} catch {
|
|
240
|
+
return {};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const pinRadius = 10;
|
|
245
|
+
const peekGap = 5;
|
|
246
|
+
const edge = 3;
|
|
247
|
+
|
|
248
|
+
function isUnset(value: unknown): value is null | undefined {
|
|
249
|
+
return value === null || value === undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function subscribeMounted() {
|
|
253
|
+
return () => {};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
type Spot = { left: number; top: number };
|
|
257
|
+
|
|
258
|
+
function useFitted<T extends HTMLElement>(place: (width: number, height: number) => Spot) {
|
|
259
|
+
const node = useRef<T | null>(null);
|
|
260
|
+
const observer = useRef<ResizeObserver | null>(null);
|
|
261
|
+
const placeRef = useRef(place);
|
|
262
|
+
useLayoutEffect(() => {
|
|
263
|
+
placeRef.current = place;
|
|
264
|
+
}, [place]);
|
|
265
|
+
const [spot, setSpot] = useState<Spot | null>(null);
|
|
266
|
+
const measure = useCallback(() => {
|
|
267
|
+
const card = node.current;
|
|
268
|
+
if (!card) return;
|
|
269
|
+
const next = placeRef.current(card.offsetWidth, card.offsetHeight);
|
|
270
|
+
setSpot((prev) => (prev && prev.left === next.left && prev.top === next.top ? prev : next));
|
|
271
|
+
}, []);
|
|
272
|
+
const ref = useCallback(
|
|
273
|
+
(card: T | null) => {
|
|
274
|
+
observer.current?.disconnect();
|
|
275
|
+
observer.current = null;
|
|
276
|
+
node.current = card;
|
|
277
|
+
if (!card) return setSpot(null);
|
|
278
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
279
|
+
observer.current = new ResizeObserver(measure);
|
|
280
|
+
observer.current.observe(card);
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
[measure],
|
|
284
|
+
);
|
|
285
|
+
useLayoutEffect(measure);
|
|
286
|
+
const { left, top } = spot ?? place(0, 0);
|
|
287
|
+
return [ref, { left, top }] as const;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function peekSpot(
|
|
291
|
+
x: number,
|
|
292
|
+
y: number,
|
|
293
|
+
bounds: { width: number; height: number },
|
|
294
|
+
width: number,
|
|
295
|
+
height: number,
|
|
296
|
+
): Spot {
|
|
297
|
+
const left = Math.max(edge, Math.min(x - width / 2, bounds.width - width - edge));
|
|
298
|
+
const below = y + pinRadius + peekGap;
|
|
299
|
+
const above = y - pinRadius - peekGap - height;
|
|
300
|
+
if (below + height <= bounds.height - edge) return { left, top: below };
|
|
301
|
+
if (above >= edge) return { left, top: above };
|
|
302
|
+
return { left, top: Math.max(edge, bounds.height - height - edge) };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** The open thread sits beside its pin, clear of the toolbar. */
|
|
306
|
+
function threadSpot(
|
|
307
|
+
x: number,
|
|
308
|
+
y: number,
|
|
309
|
+
bounds: { width: number; height: number },
|
|
310
|
+
width: number,
|
|
311
|
+
height: number,
|
|
312
|
+
): Spot {
|
|
313
|
+
const gap = 11;
|
|
314
|
+
const reserve = 57;
|
|
315
|
+
const right = x + pinRadius + gap;
|
|
316
|
+
const leftOf = x - pinRadius - gap - width;
|
|
317
|
+
const left =
|
|
318
|
+
right + width <= bounds.width - edge
|
|
319
|
+
? right
|
|
320
|
+
: leftOf >= edge
|
|
321
|
+
? leftOf
|
|
322
|
+
: Math.max(edge, Math.min(right, bounds.width - width - edge));
|
|
323
|
+
const top = Math.max(edge, Math.min(y - 17, bounds.height - height - reserve));
|
|
324
|
+
return { left, top };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function Peek({
|
|
328
|
+
x,
|
|
329
|
+
y,
|
|
330
|
+
bounds,
|
|
331
|
+
children,
|
|
332
|
+
}: {
|
|
333
|
+
x: number;
|
|
334
|
+
y: number;
|
|
335
|
+
bounds: { width: number; height: number };
|
|
336
|
+
children: ReactNode;
|
|
337
|
+
}) {
|
|
338
|
+
const [ref, style] = useFitted<HTMLDivElement>((width, height) =>
|
|
339
|
+
peekSpot(x, y, bounds, width, height),
|
|
340
|
+
);
|
|
341
|
+
return (
|
|
342
|
+
<div ref={ref} className="cs-peek" data-cs-ui="peek" style={style}>
|
|
343
|
+
{children}
|
|
344
|
+
</div>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Puts the open image or video beside a card, on whichever side has more room. */
|
|
349
|
+
function MediaPop({
|
|
350
|
+
bounds,
|
|
351
|
+
left,
|
|
352
|
+
top,
|
|
353
|
+
children,
|
|
354
|
+
}: {
|
|
355
|
+
bounds: { width: number; height: number };
|
|
356
|
+
left: number;
|
|
357
|
+
top: number;
|
|
358
|
+
children: ReactNode;
|
|
359
|
+
}) {
|
|
360
|
+
const probe = useRef<HTMLDivElement>(null);
|
|
361
|
+
const [slot, setSlot] = useState<{
|
|
362
|
+
side: "right" | "left";
|
|
363
|
+
width: number;
|
|
364
|
+
height: number;
|
|
365
|
+
} | null>(null);
|
|
366
|
+
useLayoutEffect(() => {
|
|
367
|
+
const card = probe.current?.parentElement;
|
|
368
|
+
if (!card) return;
|
|
369
|
+
const roomRight = bounds.width - (left + card.offsetWidth) - 9;
|
|
370
|
+
const roomLeft = left - 9;
|
|
371
|
+
// Both sides get the same height, so the wider one always shows more of the image or video.
|
|
372
|
+
const side = roomRight >= roomLeft ? "right" : "left";
|
|
373
|
+
const room = side === "right" ? roomRight : roomLeft;
|
|
374
|
+
const width = Math.round(Math.max(165, room));
|
|
375
|
+
const height = Math.round(Math.max(210, bounds.height - top - 12));
|
|
376
|
+
setSlot((prev) =>
|
|
377
|
+
prev && prev.side === side && prev.width === width && prev.height === height
|
|
378
|
+
? prev
|
|
379
|
+
: { side, width, height },
|
|
380
|
+
);
|
|
381
|
+
}, [bounds.height, bounds.width, left, top]);
|
|
382
|
+
return (
|
|
383
|
+
<div
|
|
384
|
+
ref={probe}
|
|
385
|
+
className={`cs-media-pop is-${slot?.side ?? "right"}`}
|
|
386
|
+
style={slot ? { width: slot.width, height: slot.height } : undefined}
|
|
387
|
+
>
|
|
388
|
+
{children}
|
|
389
|
+
</div>
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function PinCard({
|
|
394
|
+
x,
|
|
395
|
+
y,
|
|
396
|
+
bounds,
|
|
397
|
+
label,
|
|
398
|
+
children,
|
|
399
|
+
preview,
|
|
400
|
+
}: {
|
|
401
|
+
x: number;
|
|
402
|
+
y: number;
|
|
403
|
+
bounds: { width: number; height: number };
|
|
404
|
+
label: string;
|
|
405
|
+
children: ReactNode;
|
|
406
|
+
preview?: ReactNode;
|
|
407
|
+
}) {
|
|
408
|
+
const [ref, style] = useFitted<HTMLElement>((width, height) =>
|
|
409
|
+
threadSpot(x, y, bounds, width, height),
|
|
410
|
+
);
|
|
411
|
+
return (
|
|
412
|
+
<section
|
|
413
|
+
ref={ref}
|
|
414
|
+
className="cs-card cs-focus cs-hit"
|
|
415
|
+
data-cs-ui="focus"
|
|
416
|
+
aria-label={label}
|
|
417
|
+
style={style}
|
|
418
|
+
>
|
|
419
|
+
{children}
|
|
420
|
+
{preview ? (
|
|
421
|
+
<MediaPop bounds={bounds} left={style.left} top={style.top}>
|
|
422
|
+
{preview}
|
|
423
|
+
</MediaPop>
|
|
424
|
+
) : null}
|
|
425
|
+
</section>
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
type Highlight = Box & { label: string };
|
|
430
|
+
|
|
431
|
+
function sameHighlight(a: Highlight | null, b: Highlight | null) {
|
|
432
|
+
if (a === b) return true;
|
|
433
|
+
if (!a || !b) return false;
|
|
434
|
+
return (
|
|
435
|
+
a.top === b.top &&
|
|
436
|
+
a.left === b.left &&
|
|
437
|
+
a.width === b.width &&
|
|
438
|
+
a.height === b.height &&
|
|
439
|
+
a.label === b.label
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* The outline under the pointer while picking an element. It lives outside React state so a pointer move
|
|
445
|
+
* re-renders the two outline nodes, not the whole toolbar with its pins, cards and composer.
|
|
446
|
+
*/
|
|
447
|
+
function createHighlightStore() {
|
|
448
|
+
let value: Highlight | null = null;
|
|
449
|
+
const listeners = new Set<() => void>();
|
|
450
|
+
return {
|
|
451
|
+
get: () => value,
|
|
452
|
+
set(next: Highlight | null) {
|
|
453
|
+
if (sameHighlight(value, next)) return;
|
|
454
|
+
value = next;
|
|
455
|
+
for (const listener of listeners) listener();
|
|
456
|
+
},
|
|
457
|
+
subscribe(listener: () => void) {
|
|
458
|
+
listeners.add(listener);
|
|
459
|
+
return () => {
|
|
460
|
+
listeners.delete(listener);
|
|
461
|
+
};
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
type HighlightStore = ReturnType<typeof createHighlightStore>;
|
|
466
|
+
|
|
467
|
+
function PickOutline({ store }: { store: HighlightStore }) {
|
|
468
|
+
const highlight = useSyncExternalStore(store.subscribe, store.get, store.get);
|
|
469
|
+
if (!highlight) return null;
|
|
470
|
+
return (
|
|
471
|
+
<>
|
|
472
|
+
<div
|
|
473
|
+
className="cs-outline is-hover"
|
|
474
|
+
style={{
|
|
475
|
+
transform: `translate(${highlight.left}px, ${highlight.top}px)`,
|
|
476
|
+
width: highlight.width,
|
|
477
|
+
height: highlight.height,
|
|
478
|
+
}}
|
|
479
|
+
/>
|
|
480
|
+
<div
|
|
481
|
+
className="cs-outline-label is-hover"
|
|
482
|
+
style={{
|
|
483
|
+
transform: `translate(${highlight.left}px, ${Math.max(3, highlight.top - 20)}px)`,
|
|
484
|
+
}}
|
|
485
|
+
>
|
|
486
|
+
{highlight.label}
|
|
487
|
+
</div>
|
|
488
|
+
</>
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* A full-screen layer that takes one freehand stroke and hands it over, in viewport coordinates. The toolbar's
|
|
494
|
+
* shield stops pointerdown at the window, so the stroke is followed from window listeners on the same phase.
|
|
495
|
+
*/
|
|
496
|
+
function DrawLayer({ onStroke }: { onStroke: (points: Point[]) => void }) {
|
|
497
|
+
const [points, setPoints] = useState<Point[]>([]);
|
|
498
|
+
const layer = useRef<HTMLDivElement>(null);
|
|
499
|
+
const strokeDone = useRef(onStroke);
|
|
500
|
+
useEffect(() => {
|
|
501
|
+
strokeDone.current = onStroke;
|
|
502
|
+
}, [onStroke]);
|
|
503
|
+
useEffect(() => {
|
|
504
|
+
let drawing: Point[] | null = null;
|
|
505
|
+
const onDown = (event: PointerEvent) => {
|
|
506
|
+
if (event.button !== 0 || event.target !== layer.current) return;
|
|
507
|
+
event.preventDefault();
|
|
508
|
+
drawing = [{ x: event.clientX, y: event.clientY }];
|
|
509
|
+
setPoints(drawing);
|
|
510
|
+
};
|
|
511
|
+
const onMove = (event: PointerEvent) => {
|
|
512
|
+
if (!drawing) return;
|
|
513
|
+
const events = event.getCoalescedEvents?.() ?? [];
|
|
514
|
+
for (const each of events.length ? events : [event])
|
|
515
|
+
drawing.push({ x: each.clientX, y: each.clientY });
|
|
516
|
+
setPoints([...drawing]);
|
|
517
|
+
};
|
|
518
|
+
const onUp = (event: PointerEvent) => {
|
|
519
|
+
const stroke = drawing;
|
|
520
|
+
drawing = null;
|
|
521
|
+
setPoints([]);
|
|
522
|
+
if (stroke && event.type === "pointerup")
|
|
523
|
+
strokeDone.current([...stroke, { x: event.clientX, y: event.clientY }]);
|
|
524
|
+
};
|
|
525
|
+
window.addEventListener("pointerdown", onDown, true);
|
|
526
|
+
window.addEventListener("pointermove", onMove, true);
|
|
527
|
+
window.addEventListener("pointerup", onUp, true);
|
|
528
|
+
window.addEventListener("pointercancel", onUp, true);
|
|
529
|
+
return () => {
|
|
530
|
+
window.removeEventListener("pointerdown", onDown, true);
|
|
531
|
+
window.removeEventListener("pointermove", onMove, true);
|
|
532
|
+
window.removeEventListener("pointerup", onUp, true);
|
|
533
|
+
window.removeEventListener("pointercancel", onUp, true);
|
|
534
|
+
};
|
|
535
|
+
}, []);
|
|
536
|
+
const line = points.length > 1 ? points.map(({ x, y }) => `${x},${y}`).join(" ") : "";
|
|
537
|
+
return (
|
|
538
|
+
<div className="cs-draw cs-hit" data-cs-ui="draw-layer" ref={layer}>
|
|
539
|
+
{line ? (
|
|
540
|
+
<svg className="cs-ink" width="100%" height="100%">
|
|
541
|
+
<polyline points={line} stroke={INK_HALO} strokeWidth={6} />
|
|
542
|
+
<polyline points={line} stroke={INK} strokeWidth={3} />
|
|
543
|
+
</svg>
|
|
544
|
+
) : null}
|
|
545
|
+
</div>
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** A saved drawing, laid over the element it was stored against. */
|
|
550
|
+
function SketchInk({ sketch, box, active }: { sketch: Sketch; box: Box; active?: boolean }) {
|
|
551
|
+
const d = sketch.paths.map((path) => pathData(path)).join(" ");
|
|
552
|
+
return (
|
|
553
|
+
<svg
|
|
554
|
+
className={active ? "cs-ink is-placed is-active" : "cs-ink is-placed"}
|
|
555
|
+
data-cs-ui="sketch"
|
|
556
|
+
style={{ top: box.top, left: box.left }}
|
|
557
|
+
width={1}
|
|
558
|
+
height={1}
|
|
559
|
+
>
|
|
560
|
+
<path d={d} stroke={INK_HALO} strokeWidth={6} />
|
|
561
|
+
<path d={d} stroke={INK} strokeWidth={3} />
|
|
562
|
+
</svg>
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** Where a new comment's composer opens: right of `box`, or left of it when there's no room. */
|
|
567
|
+
function composerSpot(box: Box, host: HTMLElement) {
|
|
568
|
+
const unit = uiUnit();
|
|
569
|
+
const width = host === document.body ? window.innerWidth : host.clientWidth;
|
|
570
|
+
const composerWidth = Math.min(240 * unit, width - 12 * unit);
|
|
571
|
+
let left = box.left + box.width + 9 * unit;
|
|
572
|
+
if (left + composerWidth > width - 6 * unit) left = box.left - composerWidth - 9 * unit;
|
|
573
|
+
left = Math.max(6 * unit, Math.min(left, width - composerWidth - 6 * unit));
|
|
574
|
+
return { top: Math.max(6 * unit, box.top - 6 * unit), left };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/** What is wrong when the session route had the host token issued for another origin, in the service's words. */
|
|
578
|
+
function tokenForOtherOrigin(issuedFor: string) {
|
|
579
|
+
return `The session was issued for ${issuedFor}, but this page is ${window.location.origin}. Update the app's session route.`;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** The current page as a comment should remember it, without the toolbar's own query params. */
|
|
583
|
+
function pageHref() {
|
|
584
|
+
const url = new URL(window.location.href);
|
|
585
|
+
url.searchParams.delete("cs-thread");
|
|
586
|
+
url.searchParams.delete("cs-invite");
|
|
587
|
+
redactSecretSearchParams(url);
|
|
588
|
+
const hash = url.hash.includes("cs-thread=") || url.hash.includes("cs-invite=") ? "" : url.hash;
|
|
589
|
+
return `${url.pathname}${url.search}${hash}`;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function isTyping(target: EventTarget | null) {
|
|
593
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
594
|
+
return target.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
/** The open composer's writing box, if there is one. */
|
|
598
|
+
function composerField() {
|
|
599
|
+
return document.querySelector<HTMLTextAreaElement>('#cs-root [data-cs-ui="composer-body"]');
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/** The open composer itself, if there is one. */
|
|
603
|
+
function composerForm() {
|
|
604
|
+
return document.querySelector<HTMLFormElement>('#cs-root form[data-cs-ui="composer"]');
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/**
|
|
608
|
+
* What a comment on the whole page is anchored to: the page's main landmark, else its first laid-out
|
|
609
|
+
* top-level element. A thread needs an element to resolve against; the page itself is not one.
|
|
610
|
+
*/
|
|
611
|
+
function pageAnchorElement(): Element {
|
|
612
|
+
const main = document.querySelector("main, [role='main']");
|
|
613
|
+
if (main && !main.closest("#cs-root") && main.getClientRects().length) return main;
|
|
614
|
+
for (const child of document.body.children) {
|
|
615
|
+
if (child.id === "cs-root" || child.tagName === "SCRIPT" || child.tagName === "STYLE") continue;
|
|
616
|
+
if (child.getClientRects().length) return child;
|
|
617
|
+
}
|
|
618
|
+
return document.body;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function fileStamp() {
|
|
622
|
+
return new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function submitOnCmdEnter(event: ReactKeyboardEvent<HTMLTextAreaElement>) {
|
|
626
|
+
if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
|
|
627
|
+
event.preventDefault();
|
|
628
|
+
event.currentTarget.form?.requestSubmit();
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function Avatar({ name, size = 24 }: { name: string; size?: number }) {
|
|
633
|
+
return (
|
|
634
|
+
<span
|
|
635
|
+
className="cs-avatar"
|
|
636
|
+
style={{ width: size, height: size, fontSize: size * 0.4, background: colorFor(name) }}
|
|
637
|
+
>
|
|
638
|
+
{initials(name)}
|
|
639
|
+
</span>
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const ease = "cubic-bezier(0.2, 0.8, 0.2, 1)";
|
|
644
|
+
/** Runs a one-shot animation on `node`, unless the person asked for less motion. */
|
|
645
|
+
function play(
|
|
646
|
+
node: Element | null | undefined,
|
|
647
|
+
keyframes: Keyframe[],
|
|
648
|
+
options: KeyframeAnimationOptions,
|
|
649
|
+
) {
|
|
650
|
+
if (!node || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
651
|
+
node.animate(keyframes, options);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** What a bar tool's icon does as it turns on. Turning off only fades, through CSS. */
|
|
655
|
+
const moments = {
|
|
656
|
+
pop: (tool: Element) =>
|
|
657
|
+
play(
|
|
658
|
+
tool.querySelector(".cs-icon"),
|
|
659
|
+
[
|
|
660
|
+
{ transform: "scale(1)" },
|
|
661
|
+
{ transform: "scale(0.82)", offset: 0.35 },
|
|
662
|
+
{ transform: "scale(1.1)", offset: 0.7 },
|
|
663
|
+
{ transform: "scale(1)" },
|
|
664
|
+
],
|
|
665
|
+
{ duration: 320, easing: ease },
|
|
666
|
+
),
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
/** The toolbar's single-key shortcuts, and the tool each one presses. Comment, draw, comments, and dashboard are hidden. */
|
|
670
|
+
const SHORTCUTS: Record<string, string> = {
|
|
671
|
+
s: "shot-mode",
|
|
672
|
+
h: "pins-toggle",
|
|
673
|
+
};
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Whether a shortcut may act on this press. `s` closes the open composer, so it stands down
|
|
677
|
+
* while it holds unsent work: a press can arrive because the box lost focus (an overlay took it back, the page was
|
|
678
|
+
* clicked), and a letter must never throw away writing. `h` only toggles pins.
|
|
679
|
+
*/
|
|
680
|
+
export function shortcutAllowed(key: string, draftHasWriting: boolean) {
|
|
681
|
+
if (draftHasWriting && key !== "h") return false;
|
|
682
|
+
return true;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** The press a shortcut key gives its tool, so the bar shows which key did what. */
|
|
686
|
+
function pressTool(bar: HTMLElement | null, ui: string) {
|
|
687
|
+
play(
|
|
688
|
+
bar?.querySelector(`.cs-tool[data-cs-ui="${ui}"]`),
|
|
689
|
+
[
|
|
690
|
+
{ transform: "scale(1)" },
|
|
691
|
+
{ transform: "scale(0.88)", offset: 0.35 },
|
|
692
|
+
{ transform: "scale(1)" },
|
|
693
|
+
],
|
|
694
|
+
{ duration: 240, easing: ease },
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
type ToolProps = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "type"> & {
|
|
699
|
+
on: boolean;
|
|
700
|
+
moment?: keyof typeof moments;
|
|
701
|
+
};
|
|
702
|
+
|
|
703
|
+
/** A bar button. Its icon plays `moment` each time it turns on, and its tip stays down after a press until the pointer leaves. */
|
|
704
|
+
function Tool({
|
|
705
|
+
on,
|
|
706
|
+
moment = "pop",
|
|
707
|
+
className,
|
|
708
|
+
onPointerDown,
|
|
709
|
+
onPointerLeave,
|
|
710
|
+
...props
|
|
711
|
+
}: ToolProps) {
|
|
712
|
+
const ref = useRef<HTMLButtonElement>(null);
|
|
713
|
+
const was = useRef(on);
|
|
714
|
+
useEffect(() => {
|
|
715
|
+
if (on && !was.current && ref.current) moments[moment](ref.current);
|
|
716
|
+
was.current = on;
|
|
717
|
+
}, [on, moment]);
|
|
718
|
+
return (
|
|
719
|
+
<button
|
|
720
|
+
{...props}
|
|
721
|
+
ref={ref}
|
|
722
|
+
type="button"
|
|
723
|
+
className={["cs-tool", className, on ? "is-on" : ""].filter(Boolean).join(" ")}
|
|
724
|
+
onPointerDown={(event) => {
|
|
725
|
+
event.currentTarget.setAttribute("data-tipless", "");
|
|
726
|
+
onPointerDown?.(event);
|
|
727
|
+
}}
|
|
728
|
+
onPointerLeave={(event) => {
|
|
729
|
+
event.currentTarget.removeAttribute("data-tipless");
|
|
730
|
+
onPointerLeave?.(event);
|
|
731
|
+
}}
|
|
732
|
+
/>
|
|
733
|
+
);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Tips wait a beat the first time, then show at once while the pointer moves along the bar. */
|
|
737
|
+
function useWarmTips() {
|
|
738
|
+
const timer = useRef(0);
|
|
739
|
+
// allow-react-loops: spread onto one element as event handlers; nothing depends on their identity
|
|
740
|
+
return {
|
|
741
|
+
onPointerOver: (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
742
|
+
const bar = event.currentTarget;
|
|
743
|
+
window.clearTimeout(timer.current);
|
|
744
|
+
if (
|
|
745
|
+
!(event.target instanceof Element) ||
|
|
746
|
+
!event.target.closest(".cs-tool") ||
|
|
747
|
+
bar.hasAttribute("data-warm")
|
|
748
|
+
)
|
|
749
|
+
return;
|
|
750
|
+
timer.current = window.setTimeout(() => bar.setAttribute("data-warm", ""), 350);
|
|
751
|
+
},
|
|
752
|
+
onPointerLeave: (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
753
|
+
const bar = event.currentTarget;
|
|
754
|
+
window.clearTimeout(timer.current);
|
|
755
|
+
timer.current = window.setTimeout(() => bar.removeAttribute("data-warm"), 600);
|
|
756
|
+
},
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function dockInset(root: Element | null) {
|
|
761
|
+
return root instanceof HTMLElement && root.classList.contains("cs-inside") ? 6 : dockMargin;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** The pill. A short press still clicks a tool; a longer one drags, then snaps to the nearest edge. */
|
|
765
|
+
function CommentBar({
|
|
766
|
+
dock,
|
|
767
|
+
barRef,
|
|
768
|
+
onCommit,
|
|
769
|
+
onPlace,
|
|
770
|
+
children,
|
|
771
|
+
...tips
|
|
772
|
+
}: {
|
|
773
|
+
dock?: Dock;
|
|
774
|
+
barRef: { current: HTMLDivElement | null };
|
|
775
|
+
onCommit: (dock: Dock) => void;
|
|
776
|
+
onPlace: (frame: BarFrame | null) => void;
|
|
777
|
+
children: ReactNode;
|
|
778
|
+
} & Pick<HTMLAttributes<HTMLDivElement>, "onPointerOver" | "onPointerLeave">) {
|
|
779
|
+
const [drag, setDrag] = useState<DockPoint | null>(null);
|
|
780
|
+
const [override, setOverride] = useState<Dock | null>(null);
|
|
781
|
+
const [size, setSize] = useState({ width: 0, height: 0 });
|
|
782
|
+
const [bounds, setBounds] = useState({ width: 0, height: 0 });
|
|
783
|
+
const [margin, setMargin] = useState(dockMargin);
|
|
784
|
+
const live = useRef<DockPoint | null>(null);
|
|
785
|
+
const suppressClick = useRef(false);
|
|
786
|
+
const placeNow = useRef({
|
|
787
|
+
active: undefined as Dock | undefined,
|
|
788
|
+
drag: null as DockPoint | null,
|
|
789
|
+
});
|
|
790
|
+
const onCommitRef = useRef(onCommit);
|
|
791
|
+
const onPlaceRef = useRef(onPlace);
|
|
792
|
+
useLayoutEffect(() => {
|
|
793
|
+
onCommitRef.current = onCommit;
|
|
794
|
+
onPlaceRef.current = onPlace;
|
|
795
|
+
}, [onCommit, onPlace]);
|
|
796
|
+
const active =
|
|
797
|
+
override && dock && (override.edge !== dock.edge || override.ratio !== dock.ratio)
|
|
798
|
+
? override
|
|
799
|
+
: dock;
|
|
800
|
+
|
|
801
|
+
if (override && (!dock || (override.edge === dock.edge && override.ratio === dock.ratio))) {
|
|
802
|
+
setOverride(null);
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
useLayoutEffect(() => {
|
|
806
|
+
placeNow.current = { active, drag };
|
|
807
|
+
}, [active, drag]);
|
|
808
|
+
|
|
809
|
+
useLayoutEffect(() => {
|
|
810
|
+
const node = barRef.current;
|
|
811
|
+
const root = node?.closest("#cs-root");
|
|
812
|
+
if (!node || !(root instanceof HTMLElement)) return;
|
|
813
|
+
const publish = (
|
|
814
|
+
width: number,
|
|
815
|
+
height: number,
|
|
816
|
+
nextBounds: { width: number; height: number },
|
|
817
|
+
inset: number,
|
|
818
|
+
) => {
|
|
819
|
+
const current = placeNow.current;
|
|
820
|
+
if (!current.active) {
|
|
821
|
+
onPlaceRef.current(null);
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
if (current.drag || width < 1 || nextBounds.width < 1) return;
|
|
825
|
+
const point = placeDock(current.active, nextBounds, { width, height }, inset);
|
|
826
|
+
onPlaceRef.current({
|
|
827
|
+
edge: current.active.edge,
|
|
828
|
+
side: point.x + width / 2 <= nextBounds.width / 2 ? "start" : "end",
|
|
829
|
+
x: point.x,
|
|
830
|
+
y: point.y,
|
|
831
|
+
width,
|
|
832
|
+
height,
|
|
833
|
+
});
|
|
834
|
+
};
|
|
835
|
+
const measure = () => {
|
|
836
|
+
const width = node.offsetWidth;
|
|
837
|
+
const height = node.offsetHeight;
|
|
838
|
+
setSize((current) =>
|
|
839
|
+
current.width === width && current.height === height ? current : { width, height },
|
|
840
|
+
);
|
|
841
|
+
const nextBounds = { width: root.clientWidth, height: root.clientHeight };
|
|
842
|
+
setBounds((current) =>
|
|
843
|
+
current.width === nextBounds.width && current.height === nextBounds.height
|
|
844
|
+
? current
|
|
845
|
+
: nextBounds,
|
|
846
|
+
);
|
|
847
|
+
const nextMargin = dockInset(root);
|
|
848
|
+
setMargin((current) => (current === nextMargin ? current : nextMargin));
|
|
849
|
+
publish(width, height, nextBounds, nextMargin);
|
|
850
|
+
};
|
|
851
|
+
measure();
|
|
852
|
+
const observer = new ResizeObserver(measure);
|
|
853
|
+
observer.observe(node);
|
|
854
|
+
observer.observe(root);
|
|
855
|
+
return () => observer.disconnect();
|
|
856
|
+
}, [barRef]);
|
|
857
|
+
|
|
858
|
+
const placed =
|
|
859
|
+
drag ??
|
|
860
|
+
(active && size.width > 0 && bounds.width > 0 ? placeDock(active, bounds, size, margin) : null);
|
|
861
|
+
|
|
862
|
+
useLayoutEffect(() => {
|
|
863
|
+
placeNow.current = { active, drag };
|
|
864
|
+
if (!active) {
|
|
865
|
+
onPlaceRef.current(null);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (drag || size.width < 1 || bounds.width < 1) return;
|
|
869
|
+
const point = placeDock(active, bounds, size, margin);
|
|
870
|
+
onPlaceRef.current({
|
|
871
|
+
edge: active.edge,
|
|
872
|
+
side: point.x + size.width / 2 <= bounds.width / 2 ? "start" : "end",
|
|
873
|
+
x: point.x,
|
|
874
|
+
y: point.y,
|
|
875
|
+
width: size.width,
|
|
876
|
+
height: size.height,
|
|
877
|
+
});
|
|
878
|
+
}, [active, bounds, drag, margin, size]);
|
|
879
|
+
|
|
880
|
+
useEffect(() => {
|
|
881
|
+
const onClick = (event: MouseEvent) => {
|
|
882
|
+
if (!suppressClick.current) return;
|
|
883
|
+
suppressClick.current = false;
|
|
884
|
+
event.preventDefault();
|
|
885
|
+
event.stopPropagation();
|
|
886
|
+
event.stopImmediatePropagation();
|
|
887
|
+
};
|
|
888
|
+
window.addEventListener("click", onClick, true);
|
|
889
|
+
return () => window.removeEventListener("click", onClick, true);
|
|
890
|
+
}, []);
|
|
891
|
+
|
|
892
|
+
useEffect(() => {
|
|
893
|
+
let drag: {
|
|
894
|
+
start: { x: number; y: number };
|
|
895
|
+
grab: { x: number; y: number };
|
|
896
|
+
barSize: { width: number; height: number };
|
|
897
|
+
moved: boolean;
|
|
898
|
+
last: { x: number; y: number; t: number };
|
|
899
|
+
velocity: { x: number; y: number };
|
|
900
|
+
root: HTMLElement;
|
|
901
|
+
} | null = null;
|
|
902
|
+
const onDown = (event: PointerEvent) => {
|
|
903
|
+
if (event.button !== 0) return;
|
|
904
|
+
const target = event.target;
|
|
905
|
+
if (!(target instanceof Element) || !target.closest(".cs-bar")) return;
|
|
906
|
+
const node = barRef.current;
|
|
907
|
+
const root = node?.closest("#cs-root");
|
|
908
|
+
if (!node || !(root instanceof HTMLElement) || !node.contains(target)) return;
|
|
909
|
+
suppressClick.current = false;
|
|
910
|
+
const box = node.getBoundingClientRect();
|
|
911
|
+
drag = {
|
|
912
|
+
start: { x: event.clientX, y: event.clientY },
|
|
913
|
+
grab: { x: event.clientX - box.left, y: event.clientY - box.top },
|
|
914
|
+
barSize: { width: box.width, height: box.height },
|
|
915
|
+
moved: false,
|
|
916
|
+
last: { x: event.clientX, y: event.clientY, t: performance.now() },
|
|
917
|
+
velocity: { x: 0, y: 0 },
|
|
918
|
+
root,
|
|
919
|
+
};
|
|
920
|
+
};
|
|
921
|
+
const onMove = (event: PointerEvent) => {
|
|
922
|
+
if (!drag) return;
|
|
923
|
+
const now = performance.now();
|
|
924
|
+
const dt = now - drag.last.t;
|
|
925
|
+
if (dt > 0 && dt < 80) {
|
|
926
|
+
drag.velocity = {
|
|
927
|
+
x: (event.clientX - drag.last.x) / dt,
|
|
928
|
+
y: (event.clientY - drag.last.y) / dt,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
drag.last = { x: event.clientX, y: event.clientY, t: now };
|
|
932
|
+
if (
|
|
933
|
+
!drag.moved &&
|
|
934
|
+
Math.hypot(event.clientX - drag.start.x, event.clientY - drag.start.y) <= dockSlop
|
|
935
|
+
)
|
|
936
|
+
return;
|
|
937
|
+
if (!drag.moved) drag.moved = true;
|
|
938
|
+
event.preventDefault();
|
|
939
|
+
const rootRect = drag.root.getBoundingClientRect();
|
|
940
|
+
const next = clampBar(
|
|
941
|
+
{
|
|
942
|
+
x: event.clientX - rootRect.left - drag.grab.x,
|
|
943
|
+
y: event.clientY - rootRect.top - drag.grab.y,
|
|
944
|
+
},
|
|
945
|
+
{ width: drag.root.clientWidth, height: drag.root.clientHeight },
|
|
946
|
+
drag.barSize,
|
|
947
|
+
);
|
|
948
|
+
live.current = next;
|
|
949
|
+
setDrag(next);
|
|
950
|
+
};
|
|
951
|
+
const onUp = () => {
|
|
952
|
+
const activeDrag = drag;
|
|
953
|
+
drag = null;
|
|
954
|
+
if (!activeDrag?.moved || !live.current) return;
|
|
955
|
+
suppressClick.current = true;
|
|
956
|
+
const snapped = snapDock(
|
|
957
|
+
live.current,
|
|
958
|
+
activeDrag.velocity,
|
|
959
|
+
{ width: activeDrag.root.clientWidth, height: activeDrag.root.clientHeight },
|
|
960
|
+
activeDrag.barSize,
|
|
961
|
+
dockInset(activeDrag.root),
|
|
962
|
+
);
|
|
963
|
+
live.current = null;
|
|
964
|
+
setDrag(null);
|
|
965
|
+
setOverride({ edge: snapped.edge, ratio: snapped.ratio });
|
|
966
|
+
onCommitRef.current({ edge: snapped.edge, ratio: snapped.ratio });
|
|
967
|
+
};
|
|
968
|
+
window.addEventListener("pointerdown", onDown, true);
|
|
969
|
+
window.addEventListener("pointermove", onMove, true);
|
|
970
|
+
window.addEventListener("pointerup", onUp, true);
|
|
971
|
+
window.addEventListener("pointercancel", onUp, true);
|
|
972
|
+
return () => {
|
|
973
|
+
window.removeEventListener("pointerdown", onDown, true);
|
|
974
|
+
window.removeEventListener("pointermove", onMove, true);
|
|
975
|
+
window.removeEventListener("pointerup", onUp, true);
|
|
976
|
+
window.removeEventListener("pointercancel", onUp, true);
|
|
977
|
+
};
|
|
978
|
+
}, [barRef]);
|
|
979
|
+
|
|
980
|
+
return (
|
|
981
|
+
<div
|
|
982
|
+
{...tips}
|
|
983
|
+
ref={barRef}
|
|
984
|
+
className={["cs-bar", "cs-hit", placed ? "is-docked" : "", drag ? "is-dragging" : ""]
|
|
985
|
+
.filter(Boolean)
|
|
986
|
+
.join(" ")}
|
|
987
|
+
data-cs-ui="bar"
|
|
988
|
+
role="toolbar"
|
|
989
|
+
aria-label="Comments toolbar"
|
|
990
|
+
style={
|
|
991
|
+
placed
|
|
992
|
+
? { left: placed.x, top: placed.y, right: "auto", bottom: "auto", transform: "none" }
|
|
993
|
+
: undefined
|
|
994
|
+
}
|
|
995
|
+
>
|
|
996
|
+
{children}
|
|
997
|
+
</div>
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
export type ToolbarProps = {
|
|
1002
|
+
sessionPath?: string;
|
|
1003
|
+
pollMs?: number;
|
|
1004
|
+
/** Default bar position. Dragging the bar snaps it to an edge and overrides this until a preset is chosen. */
|
|
1005
|
+
position?: ToolbarPosition;
|
|
1006
|
+
/** "system" follows the OS appearance. Light by default so it matches most host pages. */
|
|
1007
|
+
theme?: ToolbarTheme;
|
|
1008
|
+
/**
|
|
1009
|
+
* Opens another page of the app, for example `(href) => router.navigate({ href })`.
|
|
1010
|
+
* Without it, selecting a comment from another page does a full page load.
|
|
1011
|
+
*/
|
|
1012
|
+
navigate?: (href: string) => void;
|
|
1013
|
+
/**
|
|
1014
|
+
* What makes one screen of the app different from another, when the path alone does not: the query
|
|
1015
|
+
* parameters that select a screen (`{ params: ["view"] }`), or a function that names the screen an address
|
|
1016
|
+
* is on. Without it, every query parameter but the usual tracking, paging, sorting and filtering ones counts
|
|
1017
|
+
* (`./screen.ts`). Pass the same function every render.
|
|
1018
|
+
*/
|
|
1019
|
+
screen?: ScreenRule;
|
|
1020
|
+
/**
|
|
1021
|
+
* Debug context for each new comment: errors with stacks, failed and slow requests, the steps, the build,
|
|
1022
|
+
* the environment and what the host knows (`getContext`), redacted in the browser and again by the service,
|
|
1023
|
+
* attached as `context-<stamp>.json`. Off unless given. `true` takes the default signals; an object turns
|
|
1024
|
+
* single signals on or off. Pass the same object every render or a new one: only a change of what is
|
|
1025
|
+
* captured restarts it.
|
|
1026
|
+
*/
|
|
1027
|
+
debugContext?: DebugContextConfig;
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
/** What the npm loader passes to `mount`: the host's props plus the session it already fetched. */
|
|
1031
|
+
export type RuntimeProps = ToolbarProps & {
|
|
1032
|
+
/** Older loaders don't pass this; the toolbar then reads the session route itself. */
|
|
1033
|
+
getSession?: GetSession;
|
|
1034
|
+
/**
|
|
1035
|
+
* Raised by the loader each time the host says its sign-in changed (`CommentTool.refresh()`): the toolbar
|
|
1036
|
+
* asks the session route at once. Older loaders don't pass it; the toolbar still asks on its own.
|
|
1037
|
+
*/
|
|
1038
|
+
hostCheck?: number;
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* Swaps this toolbar for the build the worker serves now, as soon as nobody is in the middle of something. The
|
|
1043
|
+
* worker announces its build over the live socket when its project object starts after a deploy; the served
|
|
1044
|
+
* build is also checked when the socket (re)connects, when the tab becomes visible, and every CHECK_EVERY_MS.
|
|
1045
|
+
* `quiet` says the swap won't lose a draft, a reply, a recording or an edit.
|
|
1046
|
+
*/
|
|
1047
|
+
function useRuntimeUpdates(apiUrl: string | undefined, live: boolean, quiet: boolean) {
|
|
1048
|
+
const [pending, setPending] = useState<string | null>(null);
|
|
1049
|
+
useEffect(() => {
|
|
1050
|
+
if (!apiUrl) return;
|
|
1051
|
+
let stopped = false;
|
|
1052
|
+
const check = async () => {
|
|
1053
|
+
const served = await servedBuild(apiUrl);
|
|
1054
|
+
if (!stopped && needsSwap(served)) setPending(served);
|
|
1055
|
+
};
|
|
1056
|
+
void check();
|
|
1057
|
+
const stopListening = onBuildAnnounced((build) => {
|
|
1058
|
+
if (!stopped && needsSwap(build)) setPending(build);
|
|
1059
|
+
});
|
|
1060
|
+
const onVisible = () => {
|
|
1061
|
+
if (document.visibilityState === "visible") void check();
|
|
1062
|
+
};
|
|
1063
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
1064
|
+
const timer = window.setInterval(() => void check(), CHECK_EVERY_MS);
|
|
1065
|
+
return () => {
|
|
1066
|
+
stopped = true;
|
|
1067
|
+
stopListening();
|
|
1068
|
+
window.clearInterval(timer);
|
|
1069
|
+
document.removeEventListener("visibilitychange", onVisible);
|
|
1070
|
+
};
|
|
1071
|
+
}, [apiUrl]);
|
|
1072
|
+
useEffect(() => {
|
|
1073
|
+
if (!apiUrl || !live) return;
|
|
1074
|
+
let stopped = false;
|
|
1075
|
+
void servedBuild(apiUrl).then((served) => {
|
|
1076
|
+
if (!stopped && needsSwap(served)) setPending(served);
|
|
1077
|
+
});
|
|
1078
|
+
return () => {
|
|
1079
|
+
stopped = true;
|
|
1080
|
+
};
|
|
1081
|
+
}, [apiUrl, live]);
|
|
1082
|
+
useEffect(() => {
|
|
1083
|
+
if (!pending || !apiUrl || !quiet) return;
|
|
1084
|
+
let stopped = false;
|
|
1085
|
+
// A swap that didn't happen is retried when the next check (or announcement) sets `pending` again.
|
|
1086
|
+
const retryLater = () => {
|
|
1087
|
+
if (!stopped) setPending(null);
|
|
1088
|
+
};
|
|
1089
|
+
swapRuntime(apiUrl, pending).then(
|
|
1090
|
+
(swapped) => {
|
|
1091
|
+
if (!swapped) retryLater();
|
|
1092
|
+
},
|
|
1093
|
+
(error) => {
|
|
1094
|
+
// eslint-disable-next-line no-console -- the browser console is where a host page developer sees a failed toolbar swap.
|
|
1095
|
+
console.warn("[comment-service] Could not switch to the newer toolbar.", error);
|
|
1096
|
+
retryLater();
|
|
1097
|
+
},
|
|
1098
|
+
);
|
|
1099
|
+
return () => {
|
|
1100
|
+
stopped = true;
|
|
1101
|
+
};
|
|
1102
|
+
}, [apiUrl, pending, quiet]);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* The toolbar for as long as the host app has someone signed in. The host's session route is asked again at the
|
|
1107
|
+
* moments `watchHost` names, and the loader's answer is taken at once when it passes a new `hostCheck`. When it
|
|
1108
|
+
* says nobody is signed in, every toolbar sign-in on this origin is forgotten and the toolbar leaves the page
|
|
1109
|
+
* with its socket, its threads and its open composer. When someone signs in again, or the host says its
|
|
1110
|
+
* sign-in changed (`hostCheck`) while comments stay on, a new toolbar starts that holds nothing of the last one.
|
|
1111
|
+
* A route that fails changes nothing.
|
|
1112
|
+
*/
|
|
1113
|
+
export function ToolbarApp(props: RuntimeProps) {
|
|
1114
|
+
const { getSession, sessionPath = "/api/comment-service/session", hostCheck } = props;
|
|
1115
|
+
// This build's own reading of the route, whatever loader mounted it: an older loader's cannot tell a
|
|
1116
|
+
// signed-out answer from a failed request.
|
|
1117
|
+
const own = sessionFor(sessionPath);
|
|
1118
|
+
const loaders = getSession ?? own;
|
|
1119
|
+
const [on, setOn] = useState(true);
|
|
1120
|
+
const [generation, setGeneration] = useState(0);
|
|
1121
|
+
const checkNow = useRef<(() => void) | null>(null);
|
|
1122
|
+
useEffect(() => {
|
|
1123
|
+
let stopped = false;
|
|
1124
|
+
let wasOn = true;
|
|
1125
|
+
let serving: string | null = null;
|
|
1126
|
+
/** `told`: the host said its sign-in changed, so whoever is signed in now may be someone else. */
|
|
1127
|
+
const check = (told = false) => {
|
|
1128
|
+
void askHost(told ? loaders : own, !told).then((answer) => {
|
|
1129
|
+
if (stopped) return;
|
|
1130
|
+
switch (answer.state) {
|
|
1131
|
+
case "on": {
|
|
1132
|
+
const { apiUrl, project, environment } = answer.session;
|
|
1133
|
+
const next = `${apiUrl}\n${project}\n${environment}`;
|
|
1134
|
+
// The host token does not say who is signed in: a change of person is only known when told.
|
|
1135
|
+
if (told) forgetSignIns();
|
|
1136
|
+
if (told || !wasOn || (serving !== null && serving !== next))
|
|
1137
|
+
setGeneration((count) => count + 1);
|
|
1138
|
+
serving = next;
|
|
1139
|
+
wasOn = true;
|
|
1140
|
+
setOn(true);
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
case "off":
|
|
1144
|
+
forgetSignIns();
|
|
1145
|
+
wasOn = false;
|
|
1146
|
+
setOn(false);
|
|
1147
|
+
return;
|
|
1148
|
+
case "unknown":
|
|
1149
|
+
return;
|
|
1150
|
+
default: {
|
|
1151
|
+
const never: never = answer;
|
|
1152
|
+
return never;
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
});
|
|
1156
|
+
};
|
|
1157
|
+
const watch = watchHost(() => check());
|
|
1158
|
+
// The loader has just read the session route for its `refresh()`: its answer is the one kept.
|
|
1159
|
+
checkNow.current = () => check(true);
|
|
1160
|
+
return () => {
|
|
1161
|
+
stopped = true;
|
|
1162
|
+
watch.stop();
|
|
1163
|
+
checkNow.current = null;
|
|
1164
|
+
};
|
|
1165
|
+
}, [loaders, own]);
|
|
1166
|
+
const asked = useRef(hostCheck);
|
|
1167
|
+
useEffect(() => {
|
|
1168
|
+
if (hostCheck === asked.current) return;
|
|
1169
|
+
asked.current = hostCheck;
|
|
1170
|
+
checkNow.current?.();
|
|
1171
|
+
}, [hostCheck]);
|
|
1172
|
+
return on ? <Toolbar key={generation} {...props} /> : null;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function Toolbar({
|
|
1176
|
+
sessionPath = "/api/comment-service/session",
|
|
1177
|
+
getSession,
|
|
1178
|
+
pollMs = 4000,
|
|
1179
|
+
position = "bottom-center",
|
|
1180
|
+
theme = "light",
|
|
1181
|
+
navigate,
|
|
1182
|
+
screen: screenRule,
|
|
1183
|
+
debugContext,
|
|
1184
|
+
}: RuntimeProps) {
|
|
1185
|
+
const sessions = getSession ?? sessionFor(sessionPath);
|
|
1186
|
+
const [phase, setPhase] = useState<Phase>("boot");
|
|
1187
|
+
const [session, setSession] = useState<Session | null>(null);
|
|
1188
|
+
const [member, setMember] = useState<string | null>(null);
|
|
1189
|
+
const [threads, setThreads] = useState<Thread[]>([]);
|
|
1190
|
+
const [href, setHref] = useState(() => window.location.href);
|
|
1191
|
+
const screenFunction = typeof screenRule === "function" ? screenRule : null;
|
|
1192
|
+
const screenParams =
|
|
1193
|
+
screenRule && typeof screenRule !== "function" ? JSON.stringify(screenRule.params) : null;
|
|
1194
|
+
const rule = useMemo<ScreenRule | null>(
|
|
1195
|
+
() =>
|
|
1196
|
+
screenFunction ??
|
|
1197
|
+
(screenParams === null ? null : { params: JSON.parse(screenParams) as string[] }),
|
|
1198
|
+
[screenFunction, screenParams],
|
|
1199
|
+
);
|
|
1200
|
+
// The same object for as long as the page stays on one screen: a query that is noise (a search box writing
|
|
1201
|
+
// `?q=`) does not make the pins be found again.
|
|
1202
|
+
const [screenNow, setScreenNow] = useState(() => ({ rule, here: screenAt(href, rule) }));
|
|
1203
|
+
useEffect(() => {
|
|
1204
|
+
setScreenNow((current) => {
|
|
1205
|
+
const next = screenAt(href, rule);
|
|
1206
|
+
return current.rule === rule &&
|
|
1207
|
+
current.here.key === next.key &&
|
|
1208
|
+
current.here.route === next.route
|
|
1209
|
+
? current
|
|
1210
|
+
: { rule, here: next };
|
|
1211
|
+
});
|
|
1212
|
+
}, [href, rule]);
|
|
1213
|
+
const here: Here = screenNow.here;
|
|
1214
|
+
const route = here.route;
|
|
1215
|
+
const [surface, setSurface] = useState<Surface>("none");
|
|
1216
|
+
const [dashboardScreen, setDashboardScreen] = useState<DashboardScreen>("insights");
|
|
1217
|
+
const [dashboardFocus, setDashboardFocus] = useState<DashboardFocus | null>(null);
|
|
1218
|
+
const [mode, setMode] = useState<Mode>("browse");
|
|
1219
|
+
const [reattachId, setReattachId] = useState<string | null>(null);
|
|
1220
|
+
const [peekId, setPeekId] = useState<string | null>(null);
|
|
1221
|
+
const [focusId, setFocusId] = useState<string | null>(null);
|
|
1222
|
+
const [focusMenu, setFocusMenu] = useState(false);
|
|
1223
|
+
const [preview, setPreview] = useState<{ threadId: string; fileId: string } | null>(null);
|
|
1224
|
+
const [trayKey, setTrayKey] = useState<string | null>(null);
|
|
1225
|
+
/**
|
|
1226
|
+
* The markup editor owns the keyboard while it is open: Escape, arrows, and letters are its own. Posting waits
|
|
1227
|
+
* for it too, so a message never goes out with the image the user is still drawing on.
|
|
1228
|
+
*/
|
|
1229
|
+
const markingUp = useRef(false);
|
|
1230
|
+
const [marking, setMarking] = useState(false);
|
|
1231
|
+
const [focusSpot, setFocusSpot] = useState<{ id: string; x: number; y: number } | null>(null);
|
|
1232
|
+
/** Status painted on click, kept until a thread list already shows it or the server rejects it. */
|
|
1233
|
+
const pendingStatus = useRef(new Map<string, "open" | "resolved">());
|
|
1234
|
+
const [draft, setDraft] = useState<{
|
|
1235
|
+
anchor: Anchor;
|
|
1236
|
+
top: number;
|
|
1237
|
+
left: number;
|
|
1238
|
+
element: Element;
|
|
1239
|
+
/** The page the comment is filed against, when it was started elsewhere (a draft that followed the person). */
|
|
1240
|
+
href?: string;
|
|
1241
|
+
} | null>(null);
|
|
1242
|
+
/** While a resize drags: the size it's at. Saved to prefs when the drag ends. */
|
|
1243
|
+
const [resizingComposer, setResizingComposer] = useState<ComposerSize | null>(null);
|
|
1244
|
+
/**
|
|
1245
|
+
* A comment started from the bar's screenshot tool: the draft's page picture, by its key in the draft's files.
|
|
1246
|
+
* The composer then sits beside the picture in `ShotSurface` instead of next to an element.
|
|
1247
|
+
*/
|
|
1248
|
+
const [shot, setShot] = useState<{ key: string } | null>(null);
|
|
1249
|
+
/** The page draft is minimised to the dock while it collects captures from other pages. */
|
|
1250
|
+
const [collecting, setCollecting] = useState(false);
|
|
1251
|
+
/** A draft found in storage (after a reload, or stashed when an element comment took over), offered in the dock. */
|
|
1252
|
+
const [stored, setStored] = useState<StoredDraft | null>(null);
|
|
1253
|
+
const [savedAt, setSavedAt] = useState<number | null>(null);
|
|
1254
|
+
const [confirmDiscard, setConfirmDiscard] = useState(false);
|
|
1255
|
+
/** Shapes drawn on each capture so far, by file key; flattened into the files when the comment is posted. */
|
|
1256
|
+
const [captureShapes, setCaptureShapes] = useState<ReadonlyMap<string, MarkupShape[]>>(
|
|
1257
|
+
() => new Map(),
|
|
1258
|
+
);
|
|
1259
|
+
const withShapes = useCallback(
|
|
1260
|
+
(change: (next: Map<string, MarkupShape[]>) => void) =>
|
|
1261
|
+
setCaptureShapes((current) => {
|
|
1262
|
+
const next = new Map(current);
|
|
1263
|
+
change(next);
|
|
1264
|
+
return next;
|
|
1265
|
+
}),
|
|
1266
|
+
[],
|
|
1267
|
+
);
|
|
1268
|
+
const draftId = useRef<string | null>(null);
|
|
1269
|
+
const draftCreated = useRef(0);
|
|
1270
|
+
const draftScope = useMemo(() => {
|
|
1271
|
+
const id = member ? readJwt(member)?.memberId : undefined;
|
|
1272
|
+
if (!session || !id) return null;
|
|
1273
|
+
return `${session.apiUrl}|${session.environment}|${session.project}|${id}`;
|
|
1274
|
+
}, [session, member]);
|
|
1275
|
+
const draftStore = useMemo(
|
|
1276
|
+
() => (draftScope ? createDraftStore(draftScope) : null),
|
|
1277
|
+
[draftScope],
|
|
1278
|
+
);
|
|
1279
|
+
const previousDraftScope = useRef<string | null>(null);
|
|
1280
|
+
useEffect(() => {
|
|
1281
|
+
const previous = previousDraftScope.current;
|
|
1282
|
+
if (previous && previous !== draftScope) {
|
|
1283
|
+
const left = createDraftStore(previous);
|
|
1284
|
+
void left.clear();
|
|
1285
|
+
void left.clearRecording();
|
|
1286
|
+
}
|
|
1287
|
+
previousDraftScope.current = draftScope;
|
|
1288
|
+
}, [draftScope]);
|
|
1289
|
+
useEffect(() => {
|
|
1290
|
+
if (!draftStore || !member) return;
|
|
1291
|
+
let stale = false;
|
|
1292
|
+
// A recording that was running when the page went away is put back into its draft as a clip.
|
|
1293
|
+
// The recording row stays until that merged draft is saved, so a crash here still has the take.
|
|
1294
|
+
void Promise.all([draftStore.load(), draftStore.loadRecording()]).then(([found, running]) => {
|
|
1295
|
+
if (stale) return;
|
|
1296
|
+
if (!running || !found || running.draftId !== found.id) {
|
|
1297
|
+
setStored(found);
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
const recovered = recoverRecording(found, running);
|
|
1301
|
+
void draftStore.save(recovered).then(
|
|
1302
|
+
() => {
|
|
1303
|
+
if (stale) return;
|
|
1304
|
+
void draftStore.clearRecording();
|
|
1305
|
+
setStored(recovered);
|
|
1306
|
+
},
|
|
1307
|
+
() => {
|
|
1308
|
+
if (stale) return;
|
|
1309
|
+
setStored(found);
|
|
1310
|
+
},
|
|
1311
|
+
);
|
|
1312
|
+
});
|
|
1313
|
+
return () => {
|
|
1314
|
+
stale = true;
|
|
1315
|
+
};
|
|
1316
|
+
}, [draftStore, member]);
|
|
1317
|
+
/** What happens on the page while a clip is recorded into the draft, for the replay kept beside it. */
|
|
1318
|
+
const clipBuffer = useRef<ReplayBuffer | null>(null);
|
|
1319
|
+
const clipWatch = useRef<(() => void) | null>(null);
|
|
1320
|
+
const clipChunks = useRef<Blob[]>([]);
|
|
1321
|
+
const clipStartedAt = useRef(0);
|
|
1322
|
+
/** The screenshot tool is busy copying and drawing the page. */
|
|
1323
|
+
const [shooting, setShooting] = useState(false);
|
|
1324
|
+
const [body, setBody] = useState("");
|
|
1325
|
+
const [reply, setReply] = useState("");
|
|
1326
|
+
const [replyId, setReplyId] = useState<string | null>(null);
|
|
1327
|
+
const [name, setName] = useState("");
|
|
1328
|
+
const [profile, setProfile] = useState<Profile | null>(null);
|
|
1329
|
+
const [deliveries, setDeliveries] = useState<Record<string, Delivery>>({});
|
|
1330
|
+
const [bootHandoff] = useState(() => readHandoff());
|
|
1331
|
+
const [invite, setInvite] = useState<string | null>(bootHandoff?.invite ?? null);
|
|
1332
|
+
const [capturing, setCapturing] = useState(false);
|
|
1333
|
+
const [renaming, setRenaming] = useState<string | null>(null);
|
|
1334
|
+
const [confirmReset, setConfirmReset] = useState(false);
|
|
1335
|
+
const [error, setError] = useState("");
|
|
1336
|
+
const [toast, setToast] = useState("");
|
|
1337
|
+
const [busy, setBusy] = useState(false);
|
|
1338
|
+
const [prefs, setPrefs] = useState<Prefs>(readPrefs);
|
|
1339
|
+
const [barFrame, setBarFrame] = useState<BarFrame | null>(null);
|
|
1340
|
+
const mounted = useSyncExternalStore(
|
|
1341
|
+
subscribeMounted,
|
|
1342
|
+
() => true,
|
|
1343
|
+
() => false,
|
|
1344
|
+
);
|
|
1345
|
+
const [highlightStore] = useState(createHighlightStore);
|
|
1346
|
+
const [flash, setFlash] = useState<Box | null>(null);
|
|
1347
|
+
const [systemDark, setSystemDark] = useState(false);
|
|
1348
|
+
const screen = useScreenTier();
|
|
1349
|
+
const [settings, setSettings] = useState<Settings | null>(null);
|
|
1350
|
+
const [draftDetails, setDraftDetails] = useState<Details>(noDetails);
|
|
1351
|
+
const [draftKind, setDraftKind] = useState<ReportKind | null>(null);
|
|
1352
|
+
// Testers (tagged in the project's people settings) fill the tracker's ticket form as they comment.
|
|
1353
|
+
const [memberType, setMemberType] = useState<"client" | "tester" | null>(null);
|
|
1354
|
+
const [ticketForm, setTicketForm] = useState<TicketForm | null>(null);
|
|
1355
|
+
const [ticketFormError, setTicketFormError] = useState("");
|
|
1356
|
+
const [ticketValues, setTicketValues] = useState<TicketValues>({});
|
|
1357
|
+
const ticketFormLoading = useRef(false);
|
|
1358
|
+
useEffect(() => {
|
|
1359
|
+
if (memberType !== "tester" || !session || !member || !draft) return;
|
|
1360
|
+
if (ticketForm || ticketFormLoading.current) return;
|
|
1361
|
+
ticketFormLoading.current = true;
|
|
1362
|
+
fetchTicketForm(session, member)
|
|
1363
|
+
.then(
|
|
1364
|
+
(form) => {
|
|
1365
|
+
setTicketForm(form);
|
|
1366
|
+
setTicketFormError("");
|
|
1367
|
+
},
|
|
1368
|
+
(caught: unknown) =>
|
|
1369
|
+
setTicketFormError(
|
|
1370
|
+
caught instanceof Error ? caught.message : "Couldn’t load the ticket form.",
|
|
1371
|
+
),
|
|
1372
|
+
)
|
|
1373
|
+
.finally(() => {
|
|
1374
|
+
ticketFormLoading.current = false;
|
|
1375
|
+
});
|
|
1376
|
+
}, [memberType, session, member, draft, ticketForm]);
|
|
1377
|
+
const [editingId, setEditingId] = useState<string | null>(null);
|
|
1378
|
+
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
|
1379
|
+
const [historyId, setHistoryId] = useState<string | null>(null);
|
|
1380
|
+
const [linked, setLinked] = useState<string[]>([]);
|
|
1381
|
+
const [envFilter, setEnvFilter] = useState("all");
|
|
1382
|
+
const [access, setAccess] = useState<Access>("edit");
|
|
1383
|
+
const [disabled, setDisabled] = useState(false);
|
|
1384
|
+
const [signIn, setSignIn] = useState<SignInOptions | null>(null);
|
|
1385
|
+
const [msfWaiting, setMsfWaiting] = useState(false);
|
|
1386
|
+
const msfAttempt = useRef<{ state: string; verifier: string; stop: () => void } | null>(null);
|
|
1387
|
+
const readOnly = access === "view";
|
|
1388
|
+
const readOnlyRef = useRef(readOnly);
|
|
1389
|
+
const threadsRef = useRef(threads);
|
|
1390
|
+
const modeRef = useRef(mode);
|
|
1391
|
+
const draftPicked = useRef(new Map<string, Member>());
|
|
1392
|
+
const replyPicked = useRef(new Map<string, Member>());
|
|
1393
|
+
const draftShot = useRef<Promise<string | null> | null>(null);
|
|
1394
|
+
const replyShot = useRef<Promise<string | null> | null>(null);
|
|
1395
|
+
/** The page as it was when the draft (or reply) opened: its screenshot and any @mention crop come from it. */
|
|
1396
|
+
const draftPage = useRef<PageShot | null>(null);
|
|
1397
|
+
const replyPage = useRef<PageShot | null>(null);
|
|
1398
|
+
const replySnapped = useRef(false);
|
|
1399
|
+
const pendingShot = useRef<{ kind: "draft" | "reply"; cancel: () => void } | null>(null);
|
|
1400
|
+
const cancelScheduledSnapshot = useCallback((kind: "draft" | "reply") => {
|
|
1401
|
+
if (pendingShot.current?.kind !== kind) return;
|
|
1402
|
+
pendingShot.current.cancel();
|
|
1403
|
+
pendingShot.current = null;
|
|
1404
|
+
setCapturing(false);
|
|
1405
|
+
}, []);
|
|
1406
|
+
const replyDrafts = useRef(new Map<string, string>());
|
|
1407
|
+
const [hasParkedReply, setHasParkedReply] = useState(false);
|
|
1408
|
+
const [pendingId, setPendingId] = useState<string | null>(bootHandoff?.thread ?? null);
|
|
1409
|
+
const [revealId, setRevealId] = useState<string | null>(null);
|
|
1410
|
+
/** A thread to show once its page has loaded. `focus` also opens its card at the pin. */
|
|
1411
|
+
const arrival = useRef<{ id: string; until: number; focus?: boolean } | null>(null);
|
|
1412
|
+
const prefsRef = useRef(prefs);
|
|
1413
|
+
|
|
1414
|
+
const linkedEnv = useCallback(
|
|
1415
|
+
(thread: Thread | undefined) =>
|
|
1416
|
+
thread?.environment && session && thread.environment !== session.environment
|
|
1417
|
+
? thread.environment
|
|
1418
|
+
: undefined,
|
|
1419
|
+
[session],
|
|
1420
|
+
);
|
|
1421
|
+
/**
|
|
1422
|
+
* Whether this environment checks a new comment before it is posted, from the thread list. Null until the
|
|
1423
|
+
* list says so, and for good on a service that predates the check: then nothing is ever asked.
|
|
1424
|
+
*/
|
|
1425
|
+
const [clarityMode, setClarityMode] = useState<ClarityMode | null>(null);
|
|
1426
|
+
const clarity = useClarity(session, member, clarityMode);
|
|
1427
|
+
const { reset: resetClarity, restore: restoreClarity } = clarity;
|
|
1428
|
+
const draftFiles = useAttachments(session, member, setError);
|
|
1429
|
+
const draftFileActions = useRef(draftFiles);
|
|
1430
|
+
useEffect(() => {
|
|
1431
|
+
draftFileActions.current = draftFiles;
|
|
1432
|
+
}, [draftFiles]);
|
|
1433
|
+
const replyFiles = useAttachments(
|
|
1434
|
+
session,
|
|
1435
|
+
member,
|
|
1436
|
+
setError,
|
|
1437
|
+
linkedEnv(threads.find((thread) => thread.id === replyId)),
|
|
1438
|
+
);
|
|
1439
|
+
const savePrefs = useCallback((next: Prefs) => {
|
|
1440
|
+
setPrefs(next);
|
|
1441
|
+
localStorage.setItem(prefsKey, JSON.stringify(next));
|
|
1442
|
+
}, []);
|
|
1443
|
+
const recording = useTabRecording(setError, prefs.recordSound !== false, (recordSound) =>
|
|
1444
|
+
savePrefs({ ...prefsRef.current, recordSound }),
|
|
1445
|
+
);
|
|
1446
|
+
/** The box a recording was started from: "Type what I say" on the record bar writes into it. */
|
|
1447
|
+
const [recordInto, setRecordInto] = useState<"draft" | "reply">("draft");
|
|
1448
|
+
const recordingPhase = useRef(recording.phase);
|
|
1449
|
+
useEffect(() => {
|
|
1450
|
+
readOnlyRef.current = readOnly;
|
|
1451
|
+
threadsRef.current = threads;
|
|
1452
|
+
modeRef.current = mode;
|
|
1453
|
+
prefsRef.current = prefs;
|
|
1454
|
+
recordingPhase.current = recording.phase;
|
|
1455
|
+
}, [mode, prefs, readOnly, recording.phase, threads]);
|
|
1456
|
+
// Three minutes of steps: the debug context reads them all, the instant replay its last minute.
|
|
1457
|
+
const replayBuffer = useRef(new ReplayBuffer({ windowMs: DEBUG_STEPS_MS, maxEvents: 150 }));
|
|
1458
|
+
const replayOn = Boolean(member);
|
|
1459
|
+
const ownApi = session?.apiUrl.replace(/\/+$/, "") ?? null;
|
|
1460
|
+
const debug = useDebugContext(debugContext);
|
|
1461
|
+
const debugCapture = debug.capture;
|
|
1462
|
+
const startDebug = debug.start;
|
|
1463
|
+
const debugKey = debug.key;
|
|
1464
|
+
useEffect(() => {
|
|
1465
|
+
if (!replayOn) return;
|
|
1466
|
+
const stopActivity = watchActivity(
|
|
1467
|
+
replayBuffer.current,
|
|
1468
|
+
(url) => ownApi !== null && url.startsWith(ownApi),
|
|
1469
|
+
);
|
|
1470
|
+
// Started last and stopped first, so each puts back exactly the patches it made.
|
|
1471
|
+
const stopDebug = ownApi !== null && debugKey !== "off" ? startDebug(ownApi) : () => {};
|
|
1472
|
+
return () => {
|
|
1473
|
+
stopDebug();
|
|
1474
|
+
stopActivity();
|
|
1475
|
+
};
|
|
1476
|
+
}, [replayOn, ownApi, debugKey, startDebug]);
|
|
1477
|
+
const drafting = draft !== null;
|
|
1478
|
+
useEffect(() => {
|
|
1479
|
+
if (drafting) debugCapture.current?.mark();
|
|
1480
|
+
}, [debugCapture, drafting]);
|
|
1481
|
+
const resetReplyFiles = replyFiles.reset;
|
|
1482
|
+
useEffect(() => {
|
|
1483
|
+
const switching = replyId;
|
|
1484
|
+
cancelScheduledSnapshot("reply");
|
|
1485
|
+
resetReplyFiles();
|
|
1486
|
+
if (switching === replyId) {
|
|
1487
|
+
replySnapped.current = false;
|
|
1488
|
+
replyPicked.current = new Map();
|
|
1489
|
+
replyShot.current = null;
|
|
1490
|
+
replyPage.current = null;
|
|
1491
|
+
}
|
|
1492
|
+
}, [cancelScheduledSnapshot, replyId, resetReplyFiles]);
|
|
1493
|
+
const trayItems = draft ? draftFiles.items : replyFiles.items;
|
|
1494
|
+
if (trayKey && !pendingPreviews(trayItems).some((file) => file.id === trayKey)) setTrayKey(null);
|
|
1495
|
+
|
|
1496
|
+
/** Each thread's card has a reply box; the one being typed in holds the draft, the others keep theirs aside. */
|
|
1497
|
+
const claimReply = (threadId: string) => {
|
|
1498
|
+
if (replyId === threadId) return;
|
|
1499
|
+
if (replyId && reply.trim()) replyDrafts.current.set(replyId, reply);
|
|
1500
|
+
setReply(replyDrafts.current.get(threadId) ?? "");
|
|
1501
|
+
replyDrafts.current.delete(threadId);
|
|
1502
|
+
setReplyId(threadId);
|
|
1503
|
+
setHasParkedReply([...replyDrafts.current.values()].some((text) => text.trim()));
|
|
1504
|
+
};
|
|
1505
|
+
|
|
1506
|
+
const snapshot = useCallback(
|
|
1507
|
+
async (
|
|
1508
|
+
page: PageShot,
|
|
1509
|
+
add: (files: Array<{ blob: Blob; name: string; capture?: Capture }>) => void,
|
|
1510
|
+
sketch?: Sketch,
|
|
1511
|
+
) => {
|
|
1512
|
+
setCapturing(true);
|
|
1513
|
+
const capture = describeCapture(`${window.location.origin}${pageHref()}`);
|
|
1514
|
+
const blob = await shotPage(page, sketch).catch(() => null);
|
|
1515
|
+
setCapturing(false);
|
|
1516
|
+
if (!blob) {
|
|
1517
|
+
setError("Couldn’t capture the page.");
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
add([{ blob, name: `screenshot-${fileStamp()}.jpg`, capture }]);
|
|
1521
|
+
},
|
|
1522
|
+
[setError],
|
|
1523
|
+
);
|
|
1524
|
+
/**
|
|
1525
|
+
* Copies the page on the frame after the composer paints, so the picture and the outline around `target` come
|
|
1526
|
+
* from the moment it was picked, and draws it once the browser is idle (within SNAPSHOT_DELAY_MS), so typing
|
|
1527
|
+
* the first letters never waits on it. Posting is held until it's done, as before.
|
|
1528
|
+
*/
|
|
1529
|
+
const scheduleSnapshot = useCallback(
|
|
1530
|
+
(
|
|
1531
|
+
kind: "draft" | "reply",
|
|
1532
|
+
target: Element | null,
|
|
1533
|
+
add: (files: Array<{ blob: Blob; name: string; capture?: Capture }>) => void,
|
|
1534
|
+
sketch?: Sketch,
|
|
1535
|
+
) => {
|
|
1536
|
+
pendingShot.current?.cancel();
|
|
1537
|
+
setCapturing(true);
|
|
1538
|
+
let cancelIdle: (() => void) | null = null;
|
|
1539
|
+
let timer = 0;
|
|
1540
|
+
const frame = requestAnimationFrame(() => {
|
|
1541
|
+
timer = window.setTimeout(() => {
|
|
1542
|
+
const page = takeShot(target);
|
|
1543
|
+
(kind === "draft" ? draftPage : replyPage).current = page;
|
|
1544
|
+
cancelIdle = whenIdle(() => {
|
|
1545
|
+
pendingShot.current = null;
|
|
1546
|
+
void snapshot(page, add, sketch);
|
|
1547
|
+
}, SNAPSHOT_DELAY_MS);
|
|
1548
|
+
}, 0);
|
|
1549
|
+
});
|
|
1550
|
+
pendingShot.current = {
|
|
1551
|
+
kind,
|
|
1552
|
+
cancel: () => {
|
|
1553
|
+
cancelAnimationFrame(frame);
|
|
1554
|
+
window.clearTimeout(timer);
|
|
1555
|
+
cancelIdle?.();
|
|
1556
|
+
},
|
|
1557
|
+
};
|
|
1558
|
+
},
|
|
1559
|
+
[snapshot],
|
|
1560
|
+
);
|
|
1561
|
+
/** The @mention crop of the draft's element, from the page as the draft saw it. */
|
|
1562
|
+
const cropDraft = useCallback((element: Element, sketch?: Sketch) => {
|
|
1563
|
+
const page = draftPage.current ?? takeShot(element);
|
|
1564
|
+
return page.frame ? shotAround(page, sketch) : captureAround(element, sketch);
|
|
1565
|
+
}, []);
|
|
1566
|
+
const cropReply = useCallback((element: Element, sketch?: Sketch) => {
|
|
1567
|
+
const page = replyPage.current ?? takeShot(element);
|
|
1568
|
+
return page.frame ? shotAround(page, sketch) : captureAround(element, sketch);
|
|
1569
|
+
}, []);
|
|
1570
|
+
useEffect(() => {
|
|
1571
|
+
if (!draft) cancelScheduledSnapshot("draft");
|
|
1572
|
+
}, [cancelScheduledSnapshot, draft]);
|
|
1573
|
+
useEffect(() => () => pendingShot.current?.cancel(), []);
|
|
1574
|
+
useEffect(() => {
|
|
1575
|
+
if (!member) return;
|
|
1576
|
+
return whenIdle(() => prewarmPageImage(), PREWARM_DELAY_MS);
|
|
1577
|
+
}, [member]);
|
|
1578
|
+
|
|
1579
|
+
const pinsHidden = Boolean(prefs.pinsHidden);
|
|
1580
|
+
const barPosition = prefs.position ?? position;
|
|
1581
|
+
|
|
1582
|
+
const commitDock = useCallback(
|
|
1583
|
+
(dock: Dock) => savePrefs({ ...prefsRef.current, dock }),
|
|
1584
|
+
[savePrefs],
|
|
1585
|
+
);
|
|
1586
|
+
const placeBar = useCallback((frame: BarFrame | null) => {
|
|
1587
|
+
setBarFrame((current) => {
|
|
1588
|
+
if (!frame && !current) return current;
|
|
1589
|
+
if (
|
|
1590
|
+
frame &&
|
|
1591
|
+
current &&
|
|
1592
|
+
frame.edge === current.edge &&
|
|
1593
|
+
frame.side === current.side &&
|
|
1594
|
+
frame.x === current.x &&
|
|
1595
|
+
frame.y === current.y &&
|
|
1596
|
+
frame.width === current.width &&
|
|
1597
|
+
frame.height === current.height
|
|
1598
|
+
) {
|
|
1599
|
+
return current;
|
|
1600
|
+
}
|
|
1601
|
+
return frame;
|
|
1602
|
+
});
|
|
1603
|
+
}, []);
|
|
1604
|
+
|
|
1605
|
+
const say = useCallback((message: string) => setToast(message), []);
|
|
1606
|
+
const barRef = useRef<HTMLDivElement>(null);
|
|
1607
|
+
const warmTips = useWarmTips();
|
|
1608
|
+
|
|
1609
|
+
useEffect(() => {
|
|
1610
|
+
if (theme !== "system") return;
|
|
1611
|
+
const query = window.matchMedia("(prefers-color-scheme: dark)");
|
|
1612
|
+
const sync = () => setSystemDark(query.matches);
|
|
1613
|
+
sync();
|
|
1614
|
+
query.addEventListener("change", sync);
|
|
1615
|
+
return () => query.removeEventListener("change", sync);
|
|
1616
|
+
}, [theme]);
|
|
1617
|
+
|
|
1618
|
+
useEffect(() => {
|
|
1619
|
+
if (!toast) return;
|
|
1620
|
+
const id = window.setTimeout(() => setToast(""), 1800);
|
|
1621
|
+
return () => window.clearTimeout(id);
|
|
1622
|
+
}, [toast]);
|
|
1623
|
+
|
|
1624
|
+
useEffect(() => {
|
|
1625
|
+
injectStyles("bar");
|
|
1626
|
+
injectStyles("overlay");
|
|
1627
|
+
}, []);
|
|
1628
|
+
|
|
1629
|
+
const apiUrl = session?.apiUrl;
|
|
1630
|
+
useEffect(() => {
|
|
1631
|
+
if (apiUrl) loadFont(apiUrl);
|
|
1632
|
+
}, [apiUrl]);
|
|
1633
|
+
|
|
1634
|
+
/**
|
|
1635
|
+
* The draft dock stands in for the bar while a draft is waiting: a page draft minimised to collect captures,
|
|
1636
|
+
* or one found in storage to resume. Its styles live in the panel sheet, so it needs that sheet too.
|
|
1637
|
+
*/
|
|
1638
|
+
const draftDock = draftDockMode({
|
|
1639
|
+
signedIn: member !== null && !disabled,
|
|
1640
|
+
pageDraft: draft !== null && shot !== null,
|
|
1641
|
+
collecting,
|
|
1642
|
+
stored: stored !== null,
|
|
1643
|
+
});
|
|
1644
|
+
const panelStyles =
|
|
1645
|
+
surface !== "none" ||
|
|
1646
|
+
draft !== null ||
|
|
1647
|
+
peekId !== null ||
|
|
1648
|
+
focusId !== null ||
|
|
1649
|
+
draftDock !== null;
|
|
1650
|
+
useLayoutEffect(() => {
|
|
1651
|
+
if (panelStyles) injectStyles("panel");
|
|
1652
|
+
}, [panelStyles]);
|
|
1653
|
+
|
|
1654
|
+
useEffect(() => {
|
|
1655
|
+
let cancelled = false;
|
|
1656
|
+
sessions()
|
|
1657
|
+
.then((next) => {
|
|
1658
|
+
if (cancelled) return;
|
|
1659
|
+
if (!next) {
|
|
1660
|
+
setPhase("off");
|
|
1661
|
+
return;
|
|
1662
|
+
}
|
|
1663
|
+
setSession(next);
|
|
1664
|
+
adoptStoredMember(next);
|
|
1665
|
+
const stored = localStorage.getItem(storageKey(next));
|
|
1666
|
+
if (stored && memberIsCurrent(stored)) {
|
|
1667
|
+
setMember(stored);
|
|
1668
|
+
setName(readJwt(stored)?.name ?? "");
|
|
1669
|
+
}
|
|
1670
|
+
setPhase("ready");
|
|
1671
|
+
})
|
|
1672
|
+
.catch(() => {
|
|
1673
|
+
if (!cancelled) setPhase("off");
|
|
1674
|
+
});
|
|
1675
|
+
return () => {
|
|
1676
|
+
cancelled = true;
|
|
1677
|
+
};
|
|
1678
|
+
}, [sessions]);
|
|
1679
|
+
|
|
1680
|
+
useEffect(() => {
|
|
1681
|
+
const sync = () => setHref(window.location.href);
|
|
1682
|
+
sync();
|
|
1683
|
+
announceNavigation();
|
|
1684
|
+
window.addEventListener("popstate", sync);
|
|
1685
|
+
window.addEventListener("hashchange", sync);
|
|
1686
|
+
window.addEventListener(NAVIGATED, sync);
|
|
1687
|
+
return () => {
|
|
1688
|
+
window.removeEventListener("popstate", sync);
|
|
1689
|
+
window.removeEventListener("hashchange", sync);
|
|
1690
|
+
window.removeEventListener(NAVIGATED, sync);
|
|
1691
|
+
};
|
|
1692
|
+
}, []);
|
|
1693
|
+
|
|
1694
|
+
const onThreads = useCallback((next: ThreadsPayload) => {
|
|
1695
|
+
setDisabled(false);
|
|
1696
|
+
setAccess(next.access === "view" ? "view" : "edit");
|
|
1697
|
+
setMemberType(next.memberType ?? null);
|
|
1698
|
+
setClarityMode(readClarityMode(next.clarity));
|
|
1699
|
+
const painted = paintThreadStatuses(next.threads, pendingStatus.current);
|
|
1700
|
+
pendingStatus.current = painted.pending;
|
|
1701
|
+
setThreads(painted.threads);
|
|
1702
|
+
setSettings(next.settings);
|
|
1703
|
+
const names = next.linked ?? [];
|
|
1704
|
+
setLinked((current) => (current.join("\n") === names.join("\n") ? current : names));
|
|
1705
|
+
}, []);
|
|
1706
|
+
const onUnauthorized = useCallback(() => {
|
|
1707
|
+
if (session) localStorage.removeItem(storageKey(session));
|
|
1708
|
+
void draftStore?.clear();
|
|
1709
|
+
void draftStore?.clearRecording();
|
|
1710
|
+
setMember(null);
|
|
1711
|
+
}, [draftStore, session]);
|
|
1712
|
+
const onDisabled = useCallback(() => {
|
|
1713
|
+
setDisabled(true);
|
|
1714
|
+
setThreads([]);
|
|
1715
|
+
setDraft(null);
|
|
1716
|
+
setMode("browse");
|
|
1717
|
+
}, [setDisabled, setDraft, setMode, setThreads]);
|
|
1718
|
+
|
|
1719
|
+
const panelOpen = surface === "dashboard";
|
|
1720
|
+
|
|
1721
|
+
/** Opens the dashboard on Comments, with `threadId` selected when given. */
|
|
1722
|
+
const openComments = useCallback(
|
|
1723
|
+
(threadId: string | null = null) => {
|
|
1724
|
+
if (threadId) {
|
|
1725
|
+
const thread = threadsRef.current.find((each) => each.id === threadId);
|
|
1726
|
+
const environment = thread?.environment || session?.environment;
|
|
1727
|
+
setEnvFilter((current) => (current === "all" || current === environment ? current : "all"));
|
|
1728
|
+
}
|
|
1729
|
+
setDashboardFocus((current) => (threadId ? { threadId, at: (current?.at ?? 0) + 1 } : null));
|
|
1730
|
+
setDashboardScreen("inbox");
|
|
1731
|
+
setFocusId(null);
|
|
1732
|
+
setFocusMenu(false);
|
|
1733
|
+
setSurface("dashboard");
|
|
1734
|
+
},
|
|
1735
|
+
[session],
|
|
1736
|
+
);
|
|
1737
|
+
useEffect(() => {
|
|
1738
|
+
if (surface !== "unlock" || !session || member) return;
|
|
1739
|
+
let cancelled = false;
|
|
1740
|
+
// A 401 is most often a host token that expired while the page sat open: a fresh one is asked for once.
|
|
1741
|
+
void signInOptions(session)
|
|
1742
|
+
.then(async (first) => {
|
|
1743
|
+
if (first.status !== 401) return first;
|
|
1744
|
+
const fresh = await sessions({ refresh: true }).catch(() => null);
|
|
1745
|
+
return fresh ? signInOptions(fresh) : first;
|
|
1746
|
+
})
|
|
1747
|
+
.then((next) => {
|
|
1748
|
+
if (!cancelled) setSignIn(next);
|
|
1749
|
+
});
|
|
1750
|
+
return () => {
|
|
1751
|
+
cancelled = true;
|
|
1752
|
+
};
|
|
1753
|
+
}, [member, session, sessions, surface]);
|
|
1754
|
+
|
|
1755
|
+
/** The origin the session route had the host token issued for, when it is not this page's. */
|
|
1756
|
+
const wrongOrigin = useMemo(() => {
|
|
1757
|
+
const issuedFor = session ? readJwt(session.hostToken)?.origin : undefined;
|
|
1758
|
+
return issuedFor && issuedFor !== window.location.origin ? issuedFor : null;
|
|
1759
|
+
}, [session]);
|
|
1760
|
+
useEffect(() => {
|
|
1761
|
+
if (!wrongOrigin) return;
|
|
1762
|
+
console.warn(`[comment-service] ${tokenForOtherOrigin(wrongOrigin)}`);
|
|
1763
|
+
}, [wrongOrigin]);
|
|
1764
|
+
|
|
1765
|
+
// Another tab signed out of comments, or saw the host sign out: this one follows.
|
|
1766
|
+
useEffect(() => {
|
|
1767
|
+
if (!session) return;
|
|
1768
|
+
const key = storageKey(session);
|
|
1769
|
+
const onStorage = (event: StorageEvent) => {
|
|
1770
|
+
if ((event.key === key || event.key === null) && localStorage.getItem(key) === null)
|
|
1771
|
+
setMember(null);
|
|
1772
|
+
};
|
|
1773
|
+
window.addEventListener("storage", onStorage);
|
|
1774
|
+
return () => window.removeEventListener("storage", onStorage);
|
|
1775
|
+
}, [session]);
|
|
1776
|
+
|
|
1777
|
+
useEffect(() => () => msfAttempt.current?.stop(), []);
|
|
1778
|
+
|
|
1779
|
+
useEffect(() => {
|
|
1780
|
+
if (surface !== "dashboard" || !session || !member) return;
|
|
1781
|
+
let cancelled = false;
|
|
1782
|
+
getProfile(session, member)
|
|
1783
|
+
.then((next) => {
|
|
1784
|
+
if (!cancelled) setProfile(next);
|
|
1785
|
+
})
|
|
1786
|
+
.catch(() => undefined);
|
|
1787
|
+
return () => {
|
|
1788
|
+
cancelled = true;
|
|
1789
|
+
};
|
|
1790
|
+
}, [member, session, surface]);
|
|
1791
|
+
|
|
1792
|
+
const [leftDashboard, setLeftDashboard] = useState(surface !== "dashboard");
|
|
1793
|
+
if ((surface !== "dashboard") !== leftDashboard) {
|
|
1794
|
+
setLeftDashboard(surface !== "dashboard");
|
|
1795
|
+
if (surface !== "dashboard") {
|
|
1796
|
+
setConfirmReset(false);
|
|
1797
|
+
setRenaming(null);
|
|
1798
|
+
setDashboardFocus(null);
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
const openOnRoute = threads.some((thread) => thread.status === "open" && here.has(thread));
|
|
1802
|
+
const { live, refresh, visible } = useThreadsSync({
|
|
1803
|
+
session,
|
|
1804
|
+
member,
|
|
1805
|
+
pollMs,
|
|
1806
|
+
href,
|
|
1807
|
+
wanted: panelOpen || draft !== null || openOnRoute,
|
|
1808
|
+
onThreads,
|
|
1809
|
+
onUnauthorized,
|
|
1810
|
+
onDisabled,
|
|
1811
|
+
});
|
|
1812
|
+
/** The dashboard, a new comment, or an open pin — each has a box you can type @ in. */
|
|
1813
|
+
const canMention = panelOpen || draft !== null || focusId !== null;
|
|
1814
|
+
const ownMembers = useMembers(session, member, undefined, canMention);
|
|
1815
|
+
const transcribe = (wav: Blob, prompt: string, signal: AbortSignal) => {
|
|
1816
|
+
if (!session || !member) return Promise.reject(new Error("Sign in to dictate."));
|
|
1817
|
+
return transcribeAudio(session, member, wav, prompt, signal);
|
|
1818
|
+
};
|
|
1819
|
+
const memberNames = ownMembers.map((person) => person.name);
|
|
1820
|
+
const keepVoice = prefs.keepVoice !== false;
|
|
1821
|
+
const setKeepVoice = (next: boolean) => savePrefs({ ...prefsRef.current, keepVoice: next });
|
|
1822
|
+
/** Narration while a clip is recording goes on the clip's clock, at the moment the piece was spoken. */
|
|
1823
|
+
const sayIntoClip = useCallback((text: string, spokenAt: number) => {
|
|
1824
|
+
clipBuffer.current?.pushAt({ type: "say", text }, spokenAt);
|
|
1825
|
+
}, []);
|
|
1826
|
+
const draftDictation = useDictation({
|
|
1827
|
+
transcribe,
|
|
1828
|
+
value: body,
|
|
1829
|
+
onChange: setBody,
|
|
1830
|
+
names: memberNames,
|
|
1831
|
+
onNote: setError,
|
|
1832
|
+
onAudio: (file) => draftFiles.add([{ blob: file, name: file.name }]),
|
|
1833
|
+
keepVoice,
|
|
1834
|
+
onKeepVoice: setKeepVoice,
|
|
1835
|
+
onSaid: sayIntoClip,
|
|
1836
|
+
});
|
|
1837
|
+
const replyDictation = useDictation({
|
|
1838
|
+
transcribe,
|
|
1839
|
+
value: reply,
|
|
1840
|
+
onChange: (value) => {
|
|
1841
|
+
setReply(value);
|
|
1842
|
+
if (!value.trim() || replySnapped.current) return;
|
|
1843
|
+
const thread = threads.find((item) => item.id === replyId);
|
|
1844
|
+
const element = thread ? elementOf(thread) : null;
|
|
1845
|
+
if (!thread || !element) return;
|
|
1846
|
+
replySnapped.current = true;
|
|
1847
|
+
scheduleSnapshot("reply", element, replyFiles.add, thread.anchor.sketch);
|
|
1848
|
+
},
|
|
1849
|
+
names: memberNames,
|
|
1850
|
+
onNote: setError,
|
|
1851
|
+
onAudio: (file) => replyFiles.add([{ blob: file, name: file.name }]),
|
|
1852
|
+
keepVoice,
|
|
1853
|
+
onKeepVoice: setKeepVoice,
|
|
1854
|
+
onSaid: sayIntoClip,
|
|
1855
|
+
});
|
|
1856
|
+
const cancelDraftDictation = draftDictation.cancel;
|
|
1857
|
+
const cancelReplyDictation = replyDictation.cancel;
|
|
1858
|
+
const recordDictation = recordInto === "reply" ? replyDictation : draftDictation;
|
|
1859
|
+
const stopRecordDictation = recordDictation.stop;
|
|
1860
|
+
/** Dictation was started from the record bar, so it ends with the take. */
|
|
1861
|
+
const dictatingWithRecording = useRef(false);
|
|
1862
|
+
useEffect(() => {
|
|
1863
|
+
if (recording.phase === "recording" || !dictatingWithRecording.current) return;
|
|
1864
|
+
dictatingWithRecording.current = false;
|
|
1865
|
+
// Words still on their way are written.
|
|
1866
|
+
stopRecordDictation();
|
|
1867
|
+
}, [recording.phase, stopRecordDictation]);
|
|
1868
|
+
// Words belong to the box they started in: a discarded or replaced draft, or another thread's reply box,
|
|
1869
|
+
// gets none of them.
|
|
1870
|
+
useEffect(() => {
|
|
1871
|
+
if (!draft) return;
|
|
1872
|
+
return () => cancelDraftDictation();
|
|
1873
|
+
}, [cancelDraftDictation, draft]);
|
|
1874
|
+
useEffect(() => {
|
|
1875
|
+
if (!replyId) return;
|
|
1876
|
+
return () => cancelReplyDictation();
|
|
1877
|
+
}, [cancelReplyDictation, replyId]);
|
|
1878
|
+
const noDraftWork =
|
|
1879
|
+
mode === "browse" &&
|
|
1880
|
+
!draft &&
|
|
1881
|
+
!body.trim() &&
|
|
1882
|
+
!reply.trim() &&
|
|
1883
|
+
!hasParkedReply &&
|
|
1884
|
+
replyFiles.ids.length === 0 &&
|
|
1885
|
+
recording.phase === "idle" &&
|
|
1886
|
+
!draftDictation.busy &&
|
|
1887
|
+
!replyDictation.busy &&
|
|
1888
|
+
!capturing &&
|
|
1889
|
+
!busy &&
|
|
1890
|
+
!editingId &&
|
|
1891
|
+
!editingMessageId;
|
|
1892
|
+
useRuntimeUpdates(
|
|
1893
|
+
session?.apiUrl,
|
|
1894
|
+
live,
|
|
1895
|
+
noDraftWork && (surface === "none" || document.visibilityState === "hidden"),
|
|
1896
|
+
);
|
|
1897
|
+
const replyEnv = linkedEnv(threads.find((thread) => thread.id === (focusId ?? replyId)));
|
|
1898
|
+
const replyEnvMembers = useMembers(
|
|
1899
|
+
session,
|
|
1900
|
+
member,
|
|
1901
|
+
replyEnv,
|
|
1902
|
+
Boolean(replyEnv) && (panelOpen || focusId !== null),
|
|
1903
|
+
);
|
|
1904
|
+
|
|
1905
|
+
useEffect(() => {
|
|
1906
|
+
// This tab may be a sign-in window that came back here: hand the result to the tab that started it.
|
|
1907
|
+
if (relayMsfHandoff()) {
|
|
1908
|
+
window.close();
|
|
1909
|
+
window.setTimeout(() => setToast("Signed in. You can close this tab."), 300);
|
|
1910
|
+
}
|
|
1911
|
+
}, []);
|
|
1912
|
+
|
|
1913
|
+
useEffect(() => {
|
|
1914
|
+
stripHandoffFromUrl();
|
|
1915
|
+
}, []);
|
|
1916
|
+
|
|
1917
|
+
const dropInvite = useCallback(() => {
|
|
1918
|
+
setInvite(null);
|
|
1919
|
+
dropHandoff("invite");
|
|
1920
|
+
}, []);
|
|
1921
|
+
|
|
1922
|
+
if (phase === "ready" && !member && (pendingId || invite) && surface !== "unlock") {
|
|
1923
|
+
setSurface("unlock");
|
|
1924
|
+
}
|
|
1925
|
+
if (member && invite) {
|
|
1926
|
+
setInvite(null);
|
|
1927
|
+
dropHandoff("invite");
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
const portal = useOverlayHost(mounted);
|
|
1931
|
+
const { placed, elementFor } = usePlacement({
|
|
1932
|
+
threads,
|
|
1933
|
+
here,
|
|
1934
|
+
host: portal ?? document.body,
|
|
1935
|
+
active: Boolean(member) && (panelOpen || (!pinsHidden && openOnRoute)),
|
|
1936
|
+
panelOpen,
|
|
1937
|
+
visible,
|
|
1938
|
+
});
|
|
1939
|
+
/** The element a thread on this page is attached to, for screenshots and "Show". */
|
|
1940
|
+
const elementOf = (thread: Thread) =>
|
|
1941
|
+
here.has(thread)
|
|
1942
|
+
? (elementFor(thread.id) ?? resolveInDocument(thread.anchor, route).element)
|
|
1943
|
+
: null;
|
|
1944
|
+
|
|
1945
|
+
useEffect(() => {
|
|
1946
|
+
if (!flash) return;
|
|
1947
|
+
const id = window.setTimeout(() => setFlash(null), 1400);
|
|
1948
|
+
return () => window.clearTimeout(id);
|
|
1949
|
+
}, [flash]);
|
|
1950
|
+
|
|
1951
|
+
/** Closes an element comment. A page draft is minimised to the dock instead; nothing throws it away by accident. */
|
|
1952
|
+
const closeOrPark = useCallback(() => {
|
|
1953
|
+
if (draft && shot) {
|
|
1954
|
+
setCollecting(true);
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1957
|
+
setDraft(null);
|
|
1958
|
+
}, [draft, shot]);
|
|
1959
|
+
|
|
1960
|
+
/**
|
|
1961
|
+
* The bar's screenshot tool: copies and draws the whole page, then opens it in `ShotSurface` with a composer
|
|
1962
|
+
* beside it. Pressed again while one is open, it closes it.
|
|
1963
|
+
*/
|
|
1964
|
+
/** The draft as it stands, ready for storage; null when there is no page draft. */
|
|
1965
|
+
const storedDraftNow = useCallback((): StoredDraft | null => {
|
|
1966
|
+
if (!draft || !shot || !draftId.current) return null;
|
|
1967
|
+
return {
|
|
1968
|
+
v: 1,
|
|
1969
|
+
id: draftId.current,
|
|
1970
|
+
createdAt: draftCreated.current,
|
|
1971
|
+
updatedAt: Date.now(),
|
|
1972
|
+
href: draft.href ?? pageHref(),
|
|
1973
|
+
route: draft.anchor.route,
|
|
1974
|
+
anchor: draft.anchor,
|
|
1975
|
+
body,
|
|
1976
|
+
details: draftDetails,
|
|
1977
|
+
...(draftKind ? { kind: draftKind } : {}),
|
|
1978
|
+
ticket: ticketValues,
|
|
1979
|
+
captures: draftFiles.items.map((item) => ({
|
|
1980
|
+
key: item.key,
|
|
1981
|
+
name: item.name,
|
|
1982
|
+
type: item.contentType,
|
|
1983
|
+
blob: item.blob,
|
|
1984
|
+
capture: item.capture,
|
|
1985
|
+
markup: item.markup
|
|
1986
|
+
? { blob: item.markup.blob, name: item.markup.name, shapes: item.markup.shapes }
|
|
1987
|
+
: undefined,
|
|
1988
|
+
shapes: captureShapes.get(item.key) ?? item.markup?.shapes ?? [],
|
|
1989
|
+
})),
|
|
1990
|
+
selected: shot.key,
|
|
1991
|
+
// Open questions and the answers typed so far follow the draft through a minimise or a reload.
|
|
1992
|
+
...(clarity.hold ? { clarity: clarity.hold } : {}),
|
|
1993
|
+
};
|
|
1994
|
+
}, [
|
|
1995
|
+
body,
|
|
1996
|
+
captureShapes,
|
|
1997
|
+
clarity.hold,
|
|
1998
|
+
draft,
|
|
1999
|
+
draftDetails,
|
|
2000
|
+
draftKind,
|
|
2001
|
+
draftFiles.items,
|
|
2002
|
+
shot,
|
|
2003
|
+
ticketValues,
|
|
2004
|
+
]);
|
|
2005
|
+
const storedDraftNowRef = useRef(storedDraftNow);
|
|
2006
|
+
useEffect(() => {
|
|
2007
|
+
storedDraftNowRef.current = storedDraftNow;
|
|
2008
|
+
});
|
|
2009
|
+
// Every change to a page draft is written to the browser's storage within a moment, pictures and clips
|
|
2010
|
+
// included, so a reload, a crash or another tab can offer to pick it up. The title's "Saved" follows this.
|
|
2011
|
+
useEffect(() => {
|
|
2012
|
+
if (!draftStore) return;
|
|
2013
|
+
const timer = window.setTimeout(() => {
|
|
2014
|
+
const payload = storedDraftNow();
|
|
2015
|
+
if (!payload) return;
|
|
2016
|
+
void draftStore.save(payload).then(
|
|
2017
|
+
() => {
|
|
2018
|
+
if (draftStore.durable) setSavedAt(Date.now());
|
|
2019
|
+
},
|
|
2020
|
+
() => {},
|
|
2021
|
+
);
|
|
2022
|
+
}, 300);
|
|
2023
|
+
return () => window.clearTimeout(timer);
|
|
2024
|
+
}, [draftStore, storedDraftNow]);
|
|
2025
|
+
|
|
2026
|
+
/** Keeps the page draft in storage and offers it in the dock, so an element comment can take the composer. */
|
|
2027
|
+
const stashDraft = useCallback(() => {
|
|
2028
|
+
const payload = storedDraftNowRef.current();
|
|
2029
|
+
if (!payload || !draftStore) return;
|
|
2030
|
+
void draftStore.save(payload).catch(() => {});
|
|
2031
|
+
setStored(payload);
|
|
2032
|
+
setCollecting(false);
|
|
2033
|
+
}, [draftStore]);
|
|
2034
|
+
const pageDraftRef = useRef(false);
|
|
2035
|
+
const stashDraftRef = useRef(stashDraft);
|
|
2036
|
+
useEffect(() => {
|
|
2037
|
+
pageDraftRef.current = Boolean(draft && shot);
|
|
2038
|
+
stashDraftRef.current = stashDraft;
|
|
2039
|
+
});
|
|
2040
|
+
|
|
2041
|
+
const resetPageDraft = useCallback(() => {
|
|
2042
|
+
draftFileActions.current.reset();
|
|
2043
|
+
setCaptureShapes(new Map());
|
|
2044
|
+
draftId.current = null;
|
|
2045
|
+
setShot(null);
|
|
2046
|
+
setCollecting(false);
|
|
2047
|
+
setSavedAt(null);
|
|
2048
|
+
setBody("");
|
|
2049
|
+
setDraftDetails(noDetails);
|
|
2050
|
+
setDraftKind(null);
|
|
2051
|
+
setTicketValues({});
|
|
2052
|
+
resetClarity();
|
|
2053
|
+
}, [resetClarity]);
|
|
2054
|
+
|
|
2055
|
+
/** Throws the draft away. The first press asks; the second, within a few seconds, does it. */
|
|
2056
|
+
const discardDraft = useCallback(() => {
|
|
2057
|
+
if (!confirmDiscard) {
|
|
2058
|
+
setConfirmDiscard(true);
|
|
2059
|
+
window.setTimeout(() => setConfirmDiscard(false), 4000);
|
|
2060
|
+
return;
|
|
2061
|
+
}
|
|
2062
|
+
setConfirmDiscard(false);
|
|
2063
|
+
void draftStore?.clear();
|
|
2064
|
+
setStored(null);
|
|
2065
|
+
setDraft(null);
|
|
2066
|
+
resetPageDraft();
|
|
2067
|
+
}, [confirmDiscard, draftStore, resetPageDraft]);
|
|
2068
|
+
|
|
2069
|
+
/** Puts a stored draft back: its captures (uploaded again), its words, its drawings, open on this page. */
|
|
2070
|
+
const resumeDraft = useCallback(
|
|
2071
|
+
(found: StoredDraft) => {
|
|
2072
|
+
if (!session || !member) return;
|
|
2073
|
+
const element = pageAnchorElement();
|
|
2074
|
+
const target = portal ?? document.body;
|
|
2075
|
+
const box = placeAgainst(element, target) ?? { top: 0, left: 0, width: 0, height: 0 };
|
|
2076
|
+
setMode("browse");
|
|
2077
|
+
setReattachId(null);
|
|
2078
|
+
setSurface("none");
|
|
2079
|
+
setFocusId(null);
|
|
2080
|
+
setFocusMenu(false);
|
|
2081
|
+
draftPicked.current = new Map();
|
|
2082
|
+
draftShot.current = null;
|
|
2083
|
+
draftPage.current = null;
|
|
2084
|
+
draftFileActions.current.reset();
|
|
2085
|
+
setCaptureShapes(new Map(found.captures.map((item) => [item.key, item.shapes])));
|
|
2086
|
+
draftFileActions.current.restore(
|
|
2087
|
+
found.captures.map((item) => ({
|
|
2088
|
+
key: item.key,
|
|
2089
|
+
blob: item.blob,
|
|
2090
|
+
name: item.name,
|
|
2091
|
+
capture: item.capture,
|
|
2092
|
+
markup: item.markup,
|
|
2093
|
+
})),
|
|
2094
|
+
);
|
|
2095
|
+
draftId.current = found.id;
|
|
2096
|
+
draftCreated.current = found.createdAt;
|
|
2097
|
+
setDraft({ anchor: found.anchor, href: found.href, ...composerSpot(box, target), element });
|
|
2098
|
+
setShot({ key: found.selected ?? found.captures[0]?.key ?? "" });
|
|
2099
|
+
setBody(found.body);
|
|
2100
|
+
setDraftDetails(found.details);
|
|
2101
|
+
setDraftKind(found.kind ?? null);
|
|
2102
|
+
setTicketValues(found.ticket);
|
|
2103
|
+
restoreClarity(found.clarity ?? null);
|
|
2104
|
+
setStored(null);
|
|
2105
|
+
setCollecting(false);
|
|
2106
|
+
setSavedAt(found.updatedAt);
|
|
2107
|
+
setError("");
|
|
2108
|
+
},
|
|
2109
|
+
[member, portal, restoreClarity, session],
|
|
2110
|
+
);
|
|
2111
|
+
|
|
2112
|
+
/** Takes a picture of this page into the open draft (from the dock, or S while collecting). */
|
|
2113
|
+
const addCapture = useCallback(async () => {
|
|
2114
|
+
if (!draft || !shot || shooting) return;
|
|
2115
|
+
setShooting(true);
|
|
2116
|
+
await new Promise<void>((resolve) =>
|
|
2117
|
+
requestAnimationFrame(() => window.setTimeout(resolve, 0)),
|
|
2118
|
+
);
|
|
2119
|
+
const page = takeShot(null);
|
|
2120
|
+
const capture = describeCapture(`${window.location.origin}${pageHref()}`);
|
|
2121
|
+
const blob = await shotPage(page).catch(() => null);
|
|
2122
|
+
setShooting(false);
|
|
2123
|
+
if (!blob) {
|
|
2124
|
+
say("Couldn’t capture the page.");
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
const [key] = draftFileActions.current.add([
|
|
2128
|
+
{ blob, name: `screenshot-${fileStamp()}.jpg`, capture },
|
|
2129
|
+
]);
|
|
2130
|
+
if (!key) return;
|
|
2131
|
+
setShot({ key });
|
|
2132
|
+
say("Added to the draft");
|
|
2133
|
+
}, [draft, say, shooting, shot]);
|
|
2134
|
+
|
|
2135
|
+
/**
|
|
2136
|
+
* Records this tab into the open draft; the surface gets out of the way while it runs. Every second of the
|
|
2137
|
+
* take and everything that happens on the page meanwhile are kept in storage as they land, so a reload or a
|
|
2138
|
+
* crash mid-take keeps what was filmed; the replay goes with the clip when it is kept.
|
|
2139
|
+
*/
|
|
2140
|
+
const recordIntoDraft = useCallback(() => {
|
|
2141
|
+
if (recording.phase !== "idle" || !draftId.current) return;
|
|
2142
|
+
setCollecting(true);
|
|
2143
|
+
setRecordInto("draft");
|
|
2144
|
+
clipWatch.current?.();
|
|
2145
|
+
const buffer = new ReplayBuffer({ windowMs: RECORD_MAX_MS + 10_000, maxEvents: 400 });
|
|
2146
|
+
clipBuffer.current = buffer;
|
|
2147
|
+
clipWatch.current = watchActivity(buffer);
|
|
2148
|
+
clipChunks.current = [];
|
|
2149
|
+
clipStartedAt.current = Date.now();
|
|
2150
|
+
const id = draftId.current;
|
|
2151
|
+
const url = `${window.location.origin}${pageHref()}`;
|
|
2152
|
+
void recording.begin(draftFileActions.current.add, {
|
|
2153
|
+
onChunk: (chunk, _index, mime) => {
|
|
2154
|
+
clipChunks.current.push(chunk);
|
|
2155
|
+
void draftStore?.saveRecording({
|
|
2156
|
+
v: 1,
|
|
2157
|
+
draftId: id,
|
|
2158
|
+
mime,
|
|
2159
|
+
startedAt: clipStartedAt.current,
|
|
2160
|
+
url,
|
|
2161
|
+
chunks: clipChunks.current.slice(),
|
|
2162
|
+
events: buffer.snapshot(url)?.events ?? [],
|
|
2163
|
+
});
|
|
2164
|
+
},
|
|
2165
|
+
companions: (info) => {
|
|
2166
|
+
const file = buffer.snapshot(url, {
|
|
2167
|
+
name: info.name,
|
|
2168
|
+
startedAt: info.startedAt,
|
|
2169
|
+
durationMs: info.durationMs,
|
|
2170
|
+
});
|
|
2171
|
+
return file
|
|
2172
|
+
? [
|
|
2173
|
+
{
|
|
2174
|
+
blob: new Blob([JSON.stringify(file)], { type: "application/json" }),
|
|
2175
|
+
name: `replay-${info.stamp}.json`,
|
|
2176
|
+
},
|
|
2177
|
+
]
|
|
2178
|
+
: [];
|
|
2179
|
+
},
|
|
2180
|
+
});
|
|
2181
|
+
}, [draftStore, recording]);
|
|
2182
|
+
useEffect(() => {
|
|
2183
|
+
if (recording.phase !== "idle") return;
|
|
2184
|
+
// The take ended (kept, discarded or cancelled): nothing is running that a reload could cut short.
|
|
2185
|
+
clipWatch.current?.();
|
|
2186
|
+
clipWatch.current = null;
|
|
2187
|
+
clipBuffer.current = null;
|
|
2188
|
+
clipChunks.current = [];
|
|
2189
|
+
void draftStore?.clearRecording();
|
|
2190
|
+
}, [draftStore, recording.phase]);
|
|
2191
|
+
|
|
2192
|
+
/**
|
|
2193
|
+
* The bar's screenshot tool. With no draft: copies and draws the whole page and opens it in `ShotSurface` with
|
|
2194
|
+
* a composer beside it. With a draft open: minimises it to the dock, and from the dock, opens it again. With a
|
|
2195
|
+
* stored draft waiting: resumes it.
|
|
2196
|
+
*/
|
|
2197
|
+
const startShot = useCallback(async () => {
|
|
2198
|
+
if (readOnlyRef.current) {
|
|
2199
|
+
setToast("You can read comments here, not add them.");
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
if (!session || !member || shooting) return;
|
|
2203
|
+
if (draft && shot) {
|
|
2204
|
+
setCollecting((current) => !current);
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
if (stored) {
|
|
2208
|
+
resumeDraft(stored);
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
setMode("browse");
|
|
2212
|
+
setReattachId(null);
|
|
2213
|
+
setSurface("none");
|
|
2214
|
+
setFocusId(null);
|
|
2215
|
+
setFocusMenu(false);
|
|
2216
|
+
setDraft(null);
|
|
2217
|
+
setShooting(true);
|
|
2218
|
+
// The page is copied after this frame paints, so an open card or menu just closed is not in the picture.
|
|
2219
|
+
await new Promise<void>((resolve) =>
|
|
2220
|
+
requestAnimationFrame(() => window.setTimeout(resolve, 0)),
|
|
2221
|
+
);
|
|
2222
|
+
const element = pageAnchorElement();
|
|
2223
|
+
const page = takeShot(null);
|
|
2224
|
+
const capture = describeCapture(`${window.location.origin}${pageHref()}`);
|
|
2225
|
+
const blob = await shotPage(page).catch(() => null);
|
|
2226
|
+
setShooting(false);
|
|
2227
|
+
if (!blob) {
|
|
2228
|
+
setError("Couldn’t capture the page.");
|
|
2229
|
+
return;
|
|
2230
|
+
}
|
|
2231
|
+
const anchor = captureAnchor(element, window.location.pathname);
|
|
2232
|
+
const target = portal ?? document.body;
|
|
2233
|
+
const box = placeAgainst(element, target) ?? { top: 0, left: 0, width: 0, height: 0 };
|
|
2234
|
+
draftPicked.current = new Map();
|
|
2235
|
+
draftShot.current = null;
|
|
2236
|
+
draftPage.current = page;
|
|
2237
|
+
resetPageDraft();
|
|
2238
|
+
draftId.current = crypto.randomUUID();
|
|
2239
|
+
draftCreated.current = Date.now();
|
|
2240
|
+
const [key] = draftFileActions.current.add([
|
|
2241
|
+
{ blob, name: `screenshot-${fileStamp()}.jpg`, capture },
|
|
2242
|
+
]);
|
|
2243
|
+
if (!key) return;
|
|
2244
|
+
setDraft({ anchor, href: pageHref(), ...composerSpot(box, target), element });
|
|
2245
|
+
setShot({ key });
|
|
2246
|
+
setError("");
|
|
2247
|
+
}, [draft, member, portal, resetPageDraft, resumeDraft, session, shooting, shot, stored]);
|
|
2248
|
+
|
|
2249
|
+
const togglePins = useCallback(() => {
|
|
2250
|
+
const next = !pinsHidden;
|
|
2251
|
+
savePrefs({ ...prefs, pinsHidden: next });
|
|
2252
|
+
say(next ? "Pins hidden · H to show" : "Pins shown");
|
|
2253
|
+
}, [pinsHidden, prefs, savePrefs, say]);
|
|
2254
|
+
|
|
2255
|
+
/**
|
|
2256
|
+
* Unsent work in the open composer: the same test Post applies. A letter pressed while the box has lost focus
|
|
2257
|
+
* (an overlay taking focus back, a click on the page) reads as a shortcut, and a shortcut closing the composer
|
|
2258
|
+
* over writing is not something a letter may do.
|
|
2259
|
+
*/
|
|
2260
|
+
const draftHasWriting = Boolean(
|
|
2261
|
+
draft &&
|
|
2262
|
+
(body.trim() ||
|
|
2263
|
+
draftDictation.busy ||
|
|
2264
|
+
draftFiles.ids.length ||
|
|
2265
|
+
shot ||
|
|
2266
|
+
(settings?.fields.enabled && hasFieldValues(draftDetails))),
|
|
2267
|
+
);
|
|
2268
|
+
|
|
2269
|
+
const onKey = useRef<(event: KeyboardEvent) => void>(() => {});
|
|
2270
|
+
const handleKey = (event: KeyboardEvent) => {
|
|
2271
|
+
if (event.defaultPrevented || markingUp.current) return;
|
|
2272
|
+
if (event.key === "Escape" && (draftDictation.listening || replyDictation.listening)) {
|
|
2273
|
+
event.stopImmediatePropagation();
|
|
2274
|
+
draftDictation.stop();
|
|
2275
|
+
replyDictation.stop();
|
|
2276
|
+
return;
|
|
2277
|
+
}
|
|
2278
|
+
if (recordingPhase.current !== "idle") {
|
|
2279
|
+
if (event.key === "Escape") {
|
|
2280
|
+
event.stopImmediatePropagation();
|
|
2281
|
+
recording.cancel();
|
|
2282
|
+
}
|
|
2283
|
+
return;
|
|
2284
|
+
}
|
|
2285
|
+
const trayFiles = pendingPreviews(draft ? draftFiles.items : replyFiles.items);
|
|
2286
|
+
if (trayKey && trayFiles.some((file) => file.id === trayKey)) {
|
|
2287
|
+
if (event.key === "Escape") {
|
|
2288
|
+
event.stopImmediatePropagation();
|
|
2289
|
+
setTrayKey(null);
|
|
2290
|
+
return;
|
|
2291
|
+
}
|
|
2292
|
+
if (
|
|
2293
|
+
!isTyping(event.target) &&
|
|
2294
|
+
trayFiles.length > 1 &&
|
|
2295
|
+
(event.key === "ArrowLeft" || event.key === "ArrowRight")
|
|
2296
|
+
) {
|
|
2297
|
+
event.preventDefault();
|
|
2298
|
+
event.stopImmediatePropagation();
|
|
2299
|
+
const delta = event.key === "ArrowRight" ? 1 : -1;
|
|
2300
|
+
const index = trayFiles.findIndex((file) => file.id === trayKey);
|
|
2301
|
+
const next = trayFiles[(Math.max(0, index) + delta + trayFiles.length) % trayFiles.length];
|
|
2302
|
+
if (next) setTrayKey(next.id);
|
|
2303
|
+
return;
|
|
2304
|
+
}
|
|
2305
|
+
}
|
|
2306
|
+
if (preview) {
|
|
2307
|
+
const files = visualAttachments(
|
|
2308
|
+
threads.find((thread) => thread.id === preview.threadId)?.messages,
|
|
2309
|
+
);
|
|
2310
|
+
if (event.key === "Escape") {
|
|
2311
|
+
event.stopImmediatePropagation();
|
|
2312
|
+
setPreview(null);
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
if (
|
|
2316
|
+
!isTyping(event.target) &&
|
|
2317
|
+
files.length > 1 &&
|
|
2318
|
+
(event.key === "ArrowLeft" || event.key === "ArrowRight")
|
|
2319
|
+
) {
|
|
2320
|
+
event.preventDefault();
|
|
2321
|
+
event.stopImmediatePropagation();
|
|
2322
|
+
const delta = event.key === "ArrowRight" ? 1 : -1;
|
|
2323
|
+
const index = files.findIndex((file) => file.id === preview.fileId);
|
|
2324
|
+
const next = files[(Math.max(0, index) + delta + files.length) % files.length];
|
|
2325
|
+
if (next) setPreview({ threadId: preview.threadId, fileId: next.id });
|
|
2326
|
+
return;
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
if (event.key === "Escape") {
|
|
2330
|
+
if (mode !== "browse" || draft || surface !== "none" || focusId) {
|
|
2331
|
+
event.stopImmediatePropagation();
|
|
2332
|
+
setMode("browse");
|
|
2333
|
+
closeOrPark();
|
|
2334
|
+
setReattachId(null);
|
|
2335
|
+
setSurface("none");
|
|
2336
|
+
setFocusId(null);
|
|
2337
|
+
setFocusMenu(false);
|
|
2338
|
+
setConfirmReset(false);
|
|
2339
|
+
setRenaming(null);
|
|
2340
|
+
}
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
if (!member || event.metaKey || event.ctrlKey || event.altKey || isTyping(event.target)) return;
|
|
2344
|
+
const key = event.key.toLowerCase();
|
|
2345
|
+
// A draft minimised to the dock takes more captures from the keyboard; its writing is safe in storage.
|
|
2346
|
+
const parked = collecting && Boolean(draft && shot);
|
|
2347
|
+
if (parked && (key === "s" || key === "r")) {
|
|
2348
|
+
event.preventDefault();
|
|
2349
|
+
event.stopImmediatePropagation();
|
|
2350
|
+
if (key === "s") void addCapture();
|
|
2351
|
+
else recordIntoDraft();
|
|
2352
|
+
return;
|
|
2353
|
+
}
|
|
2354
|
+
const ui = SHORTCUTS[key];
|
|
2355
|
+
if (!ui) return;
|
|
2356
|
+
if (!shortcutAllowed(key, draftHasWriting && !parked)) {
|
|
2357
|
+
event.preventDefault();
|
|
2358
|
+
event.stopImmediatePropagation();
|
|
2359
|
+
composerField()?.focus();
|
|
2360
|
+
return;
|
|
2361
|
+
}
|
|
2362
|
+
if (key === "s") void startShot();
|
|
2363
|
+
else if (key === "h") togglePins();
|
|
2364
|
+
else return;
|
|
2365
|
+
pressTool(barRef.current, ui);
|
|
2366
|
+
event.preventDefault();
|
|
2367
|
+
event.stopImmediatePropagation();
|
|
2368
|
+
};
|
|
2369
|
+
useEffect(() => {
|
|
2370
|
+
onKey.current = handleKey;
|
|
2371
|
+
});
|
|
2372
|
+
|
|
2373
|
+
useEffect(() => {
|
|
2374
|
+
const inToolbar = (event: KeyboardEvent) =>
|
|
2375
|
+
event.target instanceof Element && Boolean(event.target.closest("#cs-root"));
|
|
2376
|
+
// Page keys in the capture phase: an open menu or dialog owns focus and swallows letters and Escape.
|
|
2377
|
+
const fromPage = (event: KeyboardEvent) => {
|
|
2378
|
+
if (!inToolbar(event)) onKey.current(event);
|
|
2379
|
+
};
|
|
2380
|
+
// Toolbar keys after its own fields had their turn. Registered once, before any app dialog opens, so
|
|
2381
|
+
// stopImmediatePropagation keeps a dialog's document-level Escape handler from also closing it.
|
|
2382
|
+
const fromToolbar = (event: KeyboardEvent) => {
|
|
2383
|
+
if (inToolbar(event)) onKey.current(event);
|
|
2384
|
+
};
|
|
2385
|
+
window.addEventListener("keydown", fromPage, true);
|
|
2386
|
+
document.addEventListener("keydown", fromToolbar);
|
|
2387
|
+
return () => {
|
|
2388
|
+
window.removeEventListener("keydown", fromPage, true);
|
|
2389
|
+
document.removeEventListener("keydown", fromToolbar);
|
|
2390
|
+
};
|
|
2391
|
+
}, []);
|
|
2392
|
+
|
|
2393
|
+
useEffect(() => shieldFromPage(), []);
|
|
2394
|
+
|
|
2395
|
+
useEffect(() => {
|
|
2396
|
+
const outline = highlightStore;
|
|
2397
|
+
if (mode !== "select" && mode !== "reattach") {
|
|
2398
|
+
outline.set(null);
|
|
2399
|
+
document.documentElement.style.cursor = "";
|
|
2400
|
+
return;
|
|
2401
|
+
}
|
|
2402
|
+
document.documentElement.style.cursor = "crosshair";
|
|
2403
|
+
const host = () => portal ?? document.body;
|
|
2404
|
+
// One hit-test per frame, however many moves arrive; the label (a walk up the DOM and React trees) is
|
|
2405
|
+
// computed only when the pointer reaches a different element.
|
|
2406
|
+
let frame = 0;
|
|
2407
|
+
let pending: MouseEvent | null = null;
|
|
2408
|
+
let under: Element | null = null;
|
|
2409
|
+
let label = "";
|
|
2410
|
+
const show = () => {
|
|
2411
|
+
frame = 0;
|
|
2412
|
+
const event = pending;
|
|
2413
|
+
pending = null;
|
|
2414
|
+
if (!event) return;
|
|
2415
|
+
if (event.target instanceof Element && event.target.closest("#cs-root")) {
|
|
2416
|
+
under = null;
|
|
2417
|
+
outline.set(null);
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2420
|
+
const element = elementAt(event.clientX, event.clientY);
|
|
2421
|
+
const box = element ? placeAgainst(element, host()) : null;
|
|
2422
|
+
if (!element || !box) {
|
|
2423
|
+
under = null;
|
|
2424
|
+
outline.set(null);
|
|
2425
|
+
return;
|
|
2426
|
+
}
|
|
2427
|
+
if (element !== under) {
|
|
2428
|
+
under = element;
|
|
2429
|
+
label = describeElement(element);
|
|
2430
|
+
}
|
|
2431
|
+
outline.set({ ...box, label });
|
|
2432
|
+
};
|
|
2433
|
+
const onMove = (event: MouseEvent) => {
|
|
2434
|
+
pending = event;
|
|
2435
|
+
if (!frame) frame = requestAnimationFrame(show);
|
|
2436
|
+
};
|
|
2437
|
+
const onClick = (event: MouseEvent) => {
|
|
2438
|
+
if (
|
|
2439
|
+
event.target instanceof Element &&
|
|
2440
|
+
event.target.closest("#cs-root button, #cs-root input, #cs-root textarea, #cs-root form")
|
|
2441
|
+
) {
|
|
2442
|
+
return;
|
|
2443
|
+
}
|
|
2444
|
+
const element = elementAt(event.clientX, event.clientY);
|
|
2445
|
+
if (!element || !session) return;
|
|
2446
|
+
event.preventDefault();
|
|
2447
|
+
event.stopPropagation();
|
|
2448
|
+
const anchor = captureAnchor(element, window.location.pathname);
|
|
2449
|
+
const target = host();
|
|
2450
|
+
const box = placeAgainst(element, target);
|
|
2451
|
+
if (modeRef.current === "reattach" && reattachId && member) {
|
|
2452
|
+
setBusy(true);
|
|
2453
|
+
updateThread(session, member, reattachId, { anchor, url: pageHref() })
|
|
2454
|
+
.then(() => refresh())
|
|
2455
|
+
.then(() => {
|
|
2456
|
+
setMode("browse");
|
|
2457
|
+
setReattachId(null);
|
|
2458
|
+
openComments(reattachId);
|
|
2459
|
+
setError("");
|
|
2460
|
+
say("Reattached");
|
|
2461
|
+
})
|
|
2462
|
+
.catch((caught) =>
|
|
2463
|
+
setError(caught instanceof Error ? caught.message : "Could not reattach."),
|
|
2464
|
+
)
|
|
2465
|
+
.finally(() => setBusy(false));
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
const spot = composerSpot(
|
|
2469
|
+
box ?? { top: event.clientY, left: event.clientX, width: 0, height: 0 },
|
|
2470
|
+
target,
|
|
2471
|
+
);
|
|
2472
|
+
if (pageDraftRef.current) stashDraftRef.current();
|
|
2473
|
+
setDraft({ anchor, ...spot, element });
|
|
2474
|
+
setShot(null);
|
|
2475
|
+
draftPicked.current = new Map();
|
|
2476
|
+
draftShot.current = null;
|
|
2477
|
+
draftPage.current = null;
|
|
2478
|
+
draftFileActions.current.reset();
|
|
2479
|
+
scheduleSnapshot("draft", element, draftFileActions.current.add);
|
|
2480
|
+
setBody("");
|
|
2481
|
+
setDraftDetails(noDetails);
|
|
2482
|
+
setDraftKind(null);
|
|
2483
|
+
resetClarity();
|
|
2484
|
+
setMode("browse");
|
|
2485
|
+
setError("");
|
|
2486
|
+
};
|
|
2487
|
+
const swallow = (event: Event) => {
|
|
2488
|
+
if (event.target instanceof Element && event.target.closest("#cs-root")) return;
|
|
2489
|
+
if (!event.type.startsWith("touch")) event.preventDefault();
|
|
2490
|
+
event.stopPropagation();
|
|
2491
|
+
};
|
|
2492
|
+
const presses = ["pointerdown", "mousedown", "pointerup", "mouseup", "touchstart", "touchend"];
|
|
2493
|
+
document.addEventListener("mousemove", onMove, true);
|
|
2494
|
+
window.addEventListener("click", onClick, true);
|
|
2495
|
+
for (const type of presses) window.addEventListener(type, swallow, true);
|
|
2496
|
+
return () => {
|
|
2497
|
+
cancelAnimationFrame(frame);
|
|
2498
|
+
outline.set(null);
|
|
2499
|
+
document.documentElement.style.cursor = "";
|
|
2500
|
+
document.removeEventListener("mousemove", onMove, true);
|
|
2501
|
+
window.removeEventListener("click", onClick, true);
|
|
2502
|
+
for (const type of presses) window.removeEventListener(type, swallow, true);
|
|
2503
|
+
};
|
|
2504
|
+
}, [
|
|
2505
|
+
highlightStore,
|
|
2506
|
+
member,
|
|
2507
|
+
mode,
|
|
2508
|
+
openComments,
|
|
2509
|
+
portal,
|
|
2510
|
+
reattachId,
|
|
2511
|
+
refresh,
|
|
2512
|
+
resetClarity,
|
|
2513
|
+
say,
|
|
2514
|
+
scheduleSnapshot,
|
|
2515
|
+
session,
|
|
2516
|
+
]);
|
|
2517
|
+
|
|
2518
|
+
/** A finished freehand stroke opens a composer on the element it was drawn over. */
|
|
2519
|
+
const onStroke = useCallback(
|
|
2520
|
+
(points: Point[]) => {
|
|
2521
|
+
const drawn = inkBox(points);
|
|
2522
|
+
if (isTap(drawn) || !session) return;
|
|
2523
|
+
const element = elementUnder(drawn);
|
|
2524
|
+
if (!element) {
|
|
2525
|
+
say("Draw over something on the page");
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2528
|
+
const sketch = sketchFor(element, [simplify(points)]);
|
|
2529
|
+
const anchor = { ...captureAnchor(element, window.location.pathname), sketch };
|
|
2530
|
+
const target = portal ?? document.body;
|
|
2531
|
+
const box = placeAgainst(element, target);
|
|
2532
|
+
const rect = element.getBoundingClientRect();
|
|
2533
|
+
const spot = composerSpot(
|
|
2534
|
+
{
|
|
2535
|
+
top: (box?.top ?? rect.top) + drawn.top - rect.top,
|
|
2536
|
+
left: (box?.left ?? rect.left) + drawn.left - rect.left,
|
|
2537
|
+
width: drawn.right - drawn.left,
|
|
2538
|
+
height: drawn.bottom - drawn.top,
|
|
2539
|
+
},
|
|
2540
|
+
target,
|
|
2541
|
+
);
|
|
2542
|
+
if (pageDraftRef.current) stashDraftRef.current();
|
|
2543
|
+
setDraft({ anchor, ...spot, element });
|
|
2544
|
+
setShot(null);
|
|
2545
|
+
draftPicked.current = new Map();
|
|
2546
|
+
draftShot.current = null;
|
|
2547
|
+
draftPage.current = null;
|
|
2548
|
+
draftFileActions.current.reset();
|
|
2549
|
+
scheduleSnapshot("draft", element, draftFileActions.current.add, sketch);
|
|
2550
|
+
setBody("");
|
|
2551
|
+
setDraftDetails(noDetails);
|
|
2552
|
+
setDraftKind(null);
|
|
2553
|
+
resetClarity();
|
|
2554
|
+
setMode("browse");
|
|
2555
|
+
setError("");
|
|
2556
|
+
},
|
|
2557
|
+
[portal, resetClarity, say, scheduleSnapshot, session],
|
|
2558
|
+
);
|
|
2559
|
+
|
|
2560
|
+
useEffect(() => {
|
|
2561
|
+
const api = {
|
|
2562
|
+
version: 1 as const,
|
|
2563
|
+
project: session?.project ?? null,
|
|
2564
|
+
environment: session?.environment ?? null,
|
|
2565
|
+
list: () => threadsRef.current,
|
|
2566
|
+
capture: (selector: string) => {
|
|
2567
|
+
const element = document.querySelector(selector);
|
|
2568
|
+
return element ? captureAnchor(element, window.location.pathname) : null;
|
|
2569
|
+
},
|
|
2570
|
+
resolve: (anchor: Anchor) => resolveInDocument(anchor, window.location.pathname).resolution,
|
|
2571
|
+
};
|
|
2572
|
+
Object.assign(window, { __COMMENT_SERVICE__: api });
|
|
2573
|
+
}, [session]);
|
|
2574
|
+
|
|
2575
|
+
const envScope =
|
|
2576
|
+
envFilter !== "all" && (envFilter === session?.environment || linked.includes(envFilter))
|
|
2577
|
+
? envFilter
|
|
2578
|
+
: "all";
|
|
2579
|
+
const scoped = useMemo(
|
|
2580
|
+
() =>
|
|
2581
|
+
envScope === "all"
|
|
2582
|
+
? placed
|
|
2583
|
+
: placed.filter((item) => (item.thread.environment || session?.environment) === envScope),
|
|
2584
|
+
[envScope, placed, session],
|
|
2585
|
+
);
|
|
2586
|
+
if (preview) {
|
|
2587
|
+
const files = visualAttachments(
|
|
2588
|
+
threads.find((thread) => thread.id === preview.threadId)?.messages,
|
|
2589
|
+
);
|
|
2590
|
+
if (!files.some((file) => file.id === preview.fileId) || focusId !== preview.threadId)
|
|
2591
|
+
setPreview(null);
|
|
2592
|
+
}
|
|
2593
|
+
const showOnPage = useCallback(
|
|
2594
|
+
(thread: Thread) => {
|
|
2595
|
+
const element =
|
|
2596
|
+
elementFor(thread.id) ?? resolveInDocument(thread.anchor, window.location.pathname).element;
|
|
2597
|
+
if (!element) return;
|
|
2598
|
+
element.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
2599
|
+
window.setTimeout(() => {
|
|
2600
|
+
const box = placeAgainst(element, portal ?? document.body);
|
|
2601
|
+
if (box) setFlash(box);
|
|
2602
|
+
}, 350);
|
|
2603
|
+
},
|
|
2604
|
+
[elementFor, portal],
|
|
2605
|
+
);
|
|
2606
|
+
|
|
2607
|
+
const pendingItem =
|
|
2608
|
+
pendingId && member ? placed.find((candidate) => candidate.thread.id === pendingId) : undefined;
|
|
2609
|
+
if (pendingItem && pendingId) {
|
|
2610
|
+
const id = pendingId;
|
|
2611
|
+
const onPage = pendingItem.status === "pinned" || pendingItem.status === "drifted";
|
|
2612
|
+
setPendingId(null);
|
|
2613
|
+
setRevealId(id);
|
|
2614
|
+
if (onPage) {
|
|
2615
|
+
setFocusId(id);
|
|
2616
|
+
setFocusMenu(false);
|
|
2617
|
+
setSurface("none");
|
|
2618
|
+
} else {
|
|
2619
|
+
const thread = threads.find((each) => each.id === id);
|
|
2620
|
+
const environment = thread?.environment || session?.environment;
|
|
2621
|
+
setEnvFilter((current) => (current === "all" || current === environment ? current : "all"));
|
|
2622
|
+
setDashboardFocus((current) => ({ threadId: id, at: (current?.at ?? 0) + 1 }));
|
|
2623
|
+
setDashboardScreen("inbox");
|
|
2624
|
+
setFocusId(null);
|
|
2625
|
+
setFocusMenu(false);
|
|
2626
|
+
setSurface("dashboard");
|
|
2627
|
+
}
|
|
2628
|
+
}
|
|
2629
|
+
const revealedHandoff = useRef<string | null>(null);
|
|
2630
|
+
useEffect(() => {
|
|
2631
|
+
if (!revealId || revealedHandoff.current === revealId) return;
|
|
2632
|
+
const item = placed.find((candidate) => candidate.thread.id === revealId);
|
|
2633
|
+
if (!item) return;
|
|
2634
|
+
revealedHandoff.current = revealId;
|
|
2635
|
+
dropHandoff("thread");
|
|
2636
|
+
if (item.status === "pinned" || item.status === "drifted") showOnPage(item.thread);
|
|
2637
|
+
else arrival.current = { id: revealId, until: Date.now() + 6000 };
|
|
2638
|
+
}, [placed, revealId, showOnPage]);
|
|
2639
|
+
|
|
2640
|
+
useEffect(() => {
|
|
2641
|
+
if (!focusId) return;
|
|
2642
|
+
const close = (event: PointerEvent) => {
|
|
2643
|
+
const target = event.target;
|
|
2644
|
+
if (target instanceof Element && target.closest("#cs-root")) return;
|
|
2645
|
+
setFocusId(null);
|
|
2646
|
+
setFocusMenu(false);
|
|
2647
|
+
};
|
|
2648
|
+
window.addEventListener("pointerdown", close, true);
|
|
2649
|
+
return () => window.removeEventListener("pointerdown", close, true);
|
|
2650
|
+
}, [focusId]);
|
|
2651
|
+
|
|
2652
|
+
useEffect(() => {
|
|
2653
|
+
const pending = arrival.current;
|
|
2654
|
+
if (!pending) return;
|
|
2655
|
+
if (Date.now() > pending.until) {
|
|
2656
|
+
arrival.current = null;
|
|
2657
|
+
return;
|
|
2658
|
+
}
|
|
2659
|
+
const item = placed.find((candidate) => candidate.thread.id === pending.id);
|
|
2660
|
+
if (!item || (item.status !== "pinned" && item.status !== "drifted")) return;
|
|
2661
|
+
arrival.current = null;
|
|
2662
|
+
showOnPage(item.thread);
|
|
2663
|
+
if (pending.focus) {
|
|
2664
|
+
setFocusId(item.thread.id);
|
|
2665
|
+
setFocusMenu(false);
|
|
2666
|
+
}
|
|
2667
|
+
}, [placed, showOnPage]);
|
|
2668
|
+
|
|
2669
|
+
/**
|
|
2670
|
+
* Shows a thread at its pin: here when it's pinned on this page, else after taking you to the page it
|
|
2671
|
+
* was left on. Says so when it can't be found on this page.
|
|
2672
|
+
*/
|
|
2673
|
+
const showThread = (item: Placed) => {
|
|
2674
|
+
const { thread } = item;
|
|
2675
|
+
setEditingId(null);
|
|
2676
|
+
const onPage = item.status === "pinned" || item.status === "drifted";
|
|
2677
|
+
const target = thread.url || thread.route;
|
|
2678
|
+
if (here.has(thread) && onPage) {
|
|
2679
|
+
setSurface("none");
|
|
2680
|
+
setFocusId(thread.id);
|
|
2681
|
+
setFocusMenu(false);
|
|
2682
|
+
showOnPage(thread);
|
|
2683
|
+
return;
|
|
2684
|
+
}
|
|
2685
|
+
// A thread from before addresses were kept shows on every screen of its path: it may still be on another.
|
|
2686
|
+
if (target === pageHref() || (here.has(thread) && !isBarePath(target))) {
|
|
2687
|
+
say("It isn’t on this page right now");
|
|
2688
|
+
return;
|
|
2689
|
+
}
|
|
2690
|
+
setSurface("none");
|
|
2691
|
+
if (navigate) {
|
|
2692
|
+
arrival.current = { id: thread.id, until: Date.now() + 6000, focus: true };
|
|
2693
|
+
navigate(target);
|
|
2694
|
+
return;
|
|
2695
|
+
}
|
|
2696
|
+
window.location.assign(linkWith(target, "cs-thread", thread.id));
|
|
2697
|
+
};
|
|
2698
|
+
|
|
2699
|
+
const [composerRef, composerStyle] = useFitted<HTMLFormElement>((width, height) => {
|
|
2700
|
+
if (!draft) return { left: 0, top: 0 };
|
|
2701
|
+
const room = overlayBounds(portal ?? document.body);
|
|
2702
|
+
return {
|
|
2703
|
+
left: Math.max(edge, Math.min(draft.left, room.width - width - edge)),
|
|
2704
|
+
top: Math.max(edge, Math.min(draft.top, room.height - height - edge)),
|
|
2705
|
+
};
|
|
2706
|
+
});
|
|
2707
|
+
const composerSize = resizingComposer ?? prefs.composerSize;
|
|
2708
|
+
|
|
2709
|
+
if (!mounted || phase !== "ready" || !session) return null;
|
|
2710
|
+
|
|
2711
|
+
const changeStatus = (thread: Thread, next: "open" | "resolved") => {
|
|
2712
|
+
if (!member || thread.status === next) return;
|
|
2713
|
+
const previous = thread.status;
|
|
2714
|
+
pendingStatus.current.set(thread.id, next);
|
|
2715
|
+
setThreads((current) =>
|
|
2716
|
+
current.map((item) => (item.id === thread.id ? { ...item, status: next } : item)),
|
|
2717
|
+
);
|
|
2718
|
+
if (next === "resolved") {
|
|
2719
|
+
if (focusId === thread.id) {
|
|
2720
|
+
setFocusId(null);
|
|
2721
|
+
setFocusMenu(false);
|
|
2722
|
+
}
|
|
2723
|
+
setPeekId((current) => (current === thread.id ? null : current));
|
|
2724
|
+
}
|
|
2725
|
+
const restore = () => {
|
|
2726
|
+
if (pendingStatus.current.get(thread.id) !== next) return;
|
|
2727
|
+
pendingStatus.current.delete(thread.id);
|
|
2728
|
+
setThreads((current) =>
|
|
2729
|
+
current.map((item) =>
|
|
2730
|
+
item.id === thread.id && item.status === next ? { ...item, status: previous } : item,
|
|
2731
|
+
),
|
|
2732
|
+
);
|
|
2733
|
+
};
|
|
2734
|
+
updateThread(session, member, thread.id, { status: next })
|
|
2735
|
+
.then(() => refresh().catch(() => false))
|
|
2736
|
+
.then(() => say(next === "resolved" ? "Resolved" : "Reopened"))
|
|
2737
|
+
.catch((caught) => {
|
|
2738
|
+
restore();
|
|
2739
|
+
const message = caught instanceof Error ? caught.message : "Couldn’t update.";
|
|
2740
|
+
setError(message);
|
|
2741
|
+
say(message);
|
|
2742
|
+
});
|
|
2743
|
+
};
|
|
2744
|
+
|
|
2745
|
+
const memberName = member ? (readJwt(member)?.name ?? name) : "";
|
|
2746
|
+
const memberId = member ? readJwt(member)?.memberId : undefined;
|
|
2747
|
+
const saveEdit =
|
|
2748
|
+
(thread: Thread, message: CommentMessage) => (text: string, mentionIds: string[]) =>
|
|
2749
|
+
editMessage(session, member!, thread.id, message.id, text, mentionIds)
|
|
2750
|
+
.then(async (result) => {
|
|
2751
|
+
setEditingMessageId(null);
|
|
2752
|
+
setError("");
|
|
2753
|
+
if (result.delivery) {
|
|
2754
|
+
setDeliveries((current) => ({ ...current, [message.id]: result.delivery! }));
|
|
2755
|
+
const note = deliveryText(result.delivery);
|
|
2756
|
+
if (note) say(note);
|
|
2757
|
+
}
|
|
2758
|
+
await refresh();
|
|
2759
|
+
say("Comment updated");
|
|
2760
|
+
})
|
|
2761
|
+
.catch((caught) =>
|
|
2762
|
+
setError(caught instanceof Error ? caught.message : "Couldn’t save the edit."),
|
|
2763
|
+
);
|
|
2764
|
+
const messageTools = (thread: Thread, message: CommentMessage) => (
|
|
2765
|
+
<MessageTools
|
|
2766
|
+
message={message}
|
|
2767
|
+
mine={!readOnly && !linkedEnv(thread) && isMine(message, memberId, memberName)}
|
|
2768
|
+
historyOpen={historyId === message.id}
|
|
2769
|
+
onHistory={() => setHistoryId((current) => (current === message.id ? null : message.id))}
|
|
2770
|
+
onEdit={() => {
|
|
2771
|
+
setHistoryId(null);
|
|
2772
|
+
setEditingMessageId(message.id);
|
|
2773
|
+
}}
|
|
2774
|
+
/>
|
|
2775
|
+
);
|
|
2776
|
+
const messageEditor = (thread: Thread, message: CommentMessage, index: number) => (
|
|
2777
|
+
<MessageEditor
|
|
2778
|
+
message={message}
|
|
2779
|
+
members={ownMembers}
|
|
2780
|
+
selfEmail={readJwt(member ?? "")?.email}
|
|
2781
|
+
allowEmpty={Boolean(message.attachments?.length) || (index === 0 && thread.fields.length > 0)}
|
|
2782
|
+
onSave={saveEdit(thread, message)}
|
|
2783
|
+
onCancel={() => setEditingMessageId(null)}
|
|
2784
|
+
/>
|
|
2785
|
+
);
|
|
2786
|
+
const host = portal ?? document.body;
|
|
2787
|
+
const inside = host !== document.body;
|
|
2788
|
+
const peek = peekId
|
|
2789
|
+
? placed.find((item) => item.thread.id === peekId && !isUnset(item.top))
|
|
2790
|
+
: undefined;
|
|
2791
|
+
const bounds = overlayBounds(host);
|
|
2792
|
+
const pinLeft = (left: number) => Math.min(Math.max(left, 11), bounds.width - 11);
|
|
2793
|
+
const focusItem = focusId ? placed.find((item) => item.thread.id === focusId) : undefined;
|
|
2794
|
+
const nextFocus =
|
|
2795
|
+
focusItem && !isUnset(focusItem.top) && !isUnset(focusItem.left)
|
|
2796
|
+
? {
|
|
2797
|
+
id: focusItem.thread.id,
|
|
2798
|
+
x: pinLeft(focusItem.left),
|
|
2799
|
+
y: focusItem.top >= 0 && focusItem.top < 12 ? 12 : focusItem.top,
|
|
2800
|
+
}
|
|
2801
|
+
: null;
|
|
2802
|
+
if (
|
|
2803
|
+
nextFocus &&
|
|
2804
|
+
(focusSpot?.id !== nextFocus.id || focusSpot.x !== nextFocus.x || focusSpot.y !== nextFocus.y)
|
|
2805
|
+
) {
|
|
2806
|
+
setFocusSpot(nextFocus);
|
|
2807
|
+
}
|
|
2808
|
+
if (focusId && focusItem?.thread.status === "resolved") {
|
|
2809
|
+
setFocusId(null);
|
|
2810
|
+
setFocusMenu(false);
|
|
2811
|
+
}
|
|
2812
|
+
const focusAt = openPinCardSpot(focusItem?.thread, nextFocus, focusSpot);
|
|
2813
|
+
const focusElement =
|
|
2814
|
+
focusItem && (focusItem.status === "pinned" || focusItem.status === "drifted")
|
|
2815
|
+
? elementOf(focusItem.thread)
|
|
2816
|
+
: null;
|
|
2817
|
+
// A drawing is its own highlight; the element it is stored against is not outlined.
|
|
2818
|
+
const focusBox =
|
|
2819
|
+
focusElement && !focusItem?.thread.anchor.sketch ? placeAgainst(focusElement, host) : null;
|
|
2820
|
+
const draftBox = draft?.anchor.sketch ? placeAgainst(draft.element, host) : null;
|
|
2821
|
+
const testerForm = memberType === "tester" ? ticketForm : null;
|
|
2822
|
+
// Post is enabled by exactly the work that a shortcut must not discard.
|
|
2823
|
+
const canPost = draftHasWriting;
|
|
2824
|
+
/** A page draft: the composer is docked beside its captures (or minimised to the dock) instead of next to an element. */
|
|
2825
|
+
const pageDraft = Boolean(draft && shot);
|
|
2826
|
+
const captures = pageDraft ? pendingPreviews(draftFiles.items) : [];
|
|
2827
|
+
|
|
2828
|
+
const changeSettings = (next: Settings) => {
|
|
2829
|
+
if (!member) return;
|
|
2830
|
+
const previous = settings;
|
|
2831
|
+
setSettings(next);
|
|
2832
|
+
setError("");
|
|
2833
|
+
saveSettings(session, member, next)
|
|
2834
|
+
.then(setSettings)
|
|
2835
|
+
.catch((caught) => {
|
|
2836
|
+
setSettings(previous);
|
|
2837
|
+
setError(caught instanceof Error ? caught.message : "Couldn’t save.");
|
|
2838
|
+
});
|
|
2839
|
+
};
|
|
2840
|
+
|
|
2841
|
+
const attachReplay = (add: (files: Array<{ blob: Blob; name: string }>) => void) => {
|
|
2842
|
+
const file = replayBuffer.current.snapshot(
|
|
2843
|
+
`${window.location.origin}${pageHref()}`,
|
|
2844
|
+
undefined,
|
|
2845
|
+
INSTANT_REPLAY_MS,
|
|
2846
|
+
);
|
|
2847
|
+
if (!file) {
|
|
2848
|
+
setError("Nothing recorded yet. Use the page, then attach it.");
|
|
2849
|
+
return;
|
|
2850
|
+
}
|
|
2851
|
+
const when = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
|
|
2852
|
+
add([
|
|
2853
|
+
{
|
|
2854
|
+
blob: new Blob([JSON.stringify(file)], { type: "application/json" }),
|
|
2855
|
+
name: `replay-${when}.json`,
|
|
2856
|
+
},
|
|
2857
|
+
]);
|
|
2858
|
+
};
|
|
2859
|
+
|
|
2860
|
+
const lock = () => {
|
|
2861
|
+
recording.cancel();
|
|
2862
|
+
localStorage.removeItem(storageKey(session));
|
|
2863
|
+
void draftStore?.clear();
|
|
2864
|
+
void draftStore?.clearRecording();
|
|
2865
|
+
setMember(null);
|
|
2866
|
+
setThreads([]);
|
|
2867
|
+
setLinked([]);
|
|
2868
|
+
setSurface("none");
|
|
2869
|
+
setFocusId(null);
|
|
2870
|
+
setFocusMenu(false);
|
|
2871
|
+
setMode("browse");
|
|
2872
|
+
setDraft(null);
|
|
2873
|
+
};
|
|
2874
|
+
|
|
2875
|
+
const reset = () => {
|
|
2876
|
+
recording.cancel();
|
|
2877
|
+
localStorage.removeItem(storageKey(session));
|
|
2878
|
+
localStorage.removeItem(prefsKey);
|
|
2879
|
+
setPrefs({});
|
|
2880
|
+
void draftStore?.clear();
|
|
2881
|
+
void draftStore?.clearRecording();
|
|
2882
|
+
setMember(null);
|
|
2883
|
+
setThreads([]);
|
|
2884
|
+
setLinked([]);
|
|
2885
|
+
setFocusId(null);
|
|
2886
|
+
setFocusMenu(false);
|
|
2887
|
+
setName("");
|
|
2888
|
+
setProfile(null);
|
|
2889
|
+
setDeliveries({});
|
|
2890
|
+
setConfirmReset(false);
|
|
2891
|
+
setMode("browse");
|
|
2892
|
+
setDraft(null);
|
|
2893
|
+
setSurface("unlock");
|
|
2894
|
+
};
|
|
2895
|
+
|
|
2896
|
+
const bar = (
|
|
2897
|
+
<CommentBar
|
|
2898
|
+
dock={prefs.dock}
|
|
2899
|
+
barRef={barRef}
|
|
2900
|
+
onCommit={commitDock}
|
|
2901
|
+
onPlace={placeBar}
|
|
2902
|
+
{...warmTips}
|
|
2903
|
+
>
|
|
2904
|
+
{!member || disabled ? (
|
|
2905
|
+
<button
|
|
2906
|
+
className="cs-bar-lock"
|
|
2907
|
+
data-cs-ui="launcher"
|
|
2908
|
+
type="button"
|
|
2909
|
+
aria-label="Comments"
|
|
2910
|
+
onClick={() => {
|
|
2911
|
+
setError("");
|
|
2912
|
+
setSurface(surface === "unlock" ? "none" : "unlock");
|
|
2913
|
+
}}
|
|
2914
|
+
>
|
|
2915
|
+
<Icon.Lock />
|
|
2916
|
+
<span>Comments</span>
|
|
2917
|
+
</button>
|
|
2918
|
+
) : (
|
|
2919
|
+
<>
|
|
2920
|
+
<Tool
|
|
2921
|
+
on={shooting || pageDraft}
|
|
2922
|
+
data-cs-ui="shot-mode"
|
|
2923
|
+
data-tip={
|
|
2924
|
+
pageDraft ? (collecting ? "Open draft S" : "Minimise draft S") : "Snapshot S"
|
|
2925
|
+
}
|
|
2926
|
+
aria-label={pageDraft ? "Your draft" : "Comment on a snapshot of this page"}
|
|
2927
|
+
aria-pressed={pageDraft}
|
|
2928
|
+
aria-busy={shooting}
|
|
2929
|
+
onClick={() => void startShot()}
|
|
2930
|
+
>
|
|
2931
|
+
<Icon.Camera size={13} />
|
|
2932
|
+
</Tool>
|
|
2933
|
+
<Tool
|
|
2934
|
+
on={pinsHidden}
|
|
2935
|
+
data-cs-ui="pins-toggle"
|
|
2936
|
+
data-tip={pinsHidden ? "Show pins H" : "Hide pins H"}
|
|
2937
|
+
aria-label={pinsHidden ? "Show pins" : "Hide pins"}
|
|
2938
|
+
aria-pressed={pinsHidden}
|
|
2939
|
+
onClick={togglePins}
|
|
2940
|
+
>
|
|
2941
|
+
<Icon.Pins hidden={pinsHidden} />
|
|
2942
|
+
</Tool>
|
|
2943
|
+
</>
|
|
2944
|
+
)}
|
|
2945
|
+
</CommentBar>
|
|
2946
|
+
);
|
|
2947
|
+
|
|
2948
|
+
const signedIn = (token: string) => {
|
|
2949
|
+
localStorage.setItem(storageKey(session), token);
|
|
2950
|
+
setMember(token);
|
|
2951
|
+
setName(readJwt(token)?.name ?? "");
|
|
2952
|
+
dropInvite();
|
|
2953
|
+
setSurface("none");
|
|
2954
|
+
};
|
|
2955
|
+
|
|
2956
|
+
const stopMsf = () => {
|
|
2957
|
+
msfAttempt.current?.stop();
|
|
2958
|
+
msfAttempt.current = null;
|
|
2959
|
+
setMsfWaiting(false);
|
|
2960
|
+
};
|
|
2961
|
+
|
|
2962
|
+
/**
|
|
2963
|
+
* Sign in through the service's identity connection: an OAuth 2.0 authorization code request with PKCE in a small window. The code
|
|
2964
|
+
* comes back to this page only: by message from the window, or, when the window lost its link to this page
|
|
2965
|
+
* (some sign-in providers cut it), through this app's own tab the window returns to.
|
|
2966
|
+
*/
|
|
2967
|
+
const startMsf = () => {
|
|
2968
|
+
const authorizeUrl = signIn?.msf?.authorizeUrl;
|
|
2969
|
+
if (!authorizeUrl) return;
|
|
2970
|
+
setError("");
|
|
2971
|
+
stopMsf();
|
|
2972
|
+
// Opened in the click itself so the browser allows it; the address is set once the request is ready.
|
|
2973
|
+
const popup = window.open("", "devaccel-msf-sign-in", "popup,width=480,height=720");
|
|
2974
|
+
let stopped = false;
|
|
2975
|
+
let attempt: { state: string; verifier: string } | null = null;
|
|
2976
|
+
let unlisten = () => {};
|
|
2977
|
+
const timer = window.setTimeout(
|
|
2978
|
+
() => fail(new Error("Sign-in didn’t finish. Try again.")),
|
|
2979
|
+
10 * 60_000,
|
|
2980
|
+
);
|
|
2981
|
+
const stop = () => {
|
|
2982
|
+
stopped = true;
|
|
2983
|
+
window.clearTimeout(timer);
|
|
2984
|
+
unlisten();
|
|
2985
|
+
};
|
|
2986
|
+
const fail = (caught: unknown) => {
|
|
2987
|
+
if (stopped) return;
|
|
2988
|
+
stopMsf();
|
|
2989
|
+
setError(caught instanceof Error ? caught.message : "Couldn’t sign in.");
|
|
2990
|
+
};
|
|
2991
|
+
unlisten = listenForMsfHandoff(new URL(session.apiUrl).origin, (handoff) => {
|
|
2992
|
+
if (stopped || !attempt || handoff.state !== attempt.state) return;
|
|
2993
|
+
// The same result can arrive by message, broadcast and storage; only the first one is used.
|
|
2994
|
+
const current = attempt;
|
|
2995
|
+
attempt = null;
|
|
2996
|
+
try {
|
|
2997
|
+
popup?.close();
|
|
2998
|
+
} catch {
|
|
2999
|
+
// the window may already be gone
|
|
3000
|
+
}
|
|
3001
|
+
if (handoff.error || !handoff.code) {
|
|
3002
|
+
fail(
|
|
3003
|
+
new Error(
|
|
3004
|
+
handoff.error === "access_denied"
|
|
3005
|
+
? "This account doesn’t have access to comments here. Ask a project manager."
|
|
3006
|
+
: "Sign-in didn’t finish. Try again.",
|
|
3007
|
+
),
|
|
3008
|
+
);
|
|
3009
|
+
return;
|
|
3010
|
+
}
|
|
3011
|
+
const code = handoff.code;
|
|
3012
|
+
sessions()
|
|
3013
|
+
.then((fresh) => {
|
|
3014
|
+
if (!fresh) throw new Error("Comments aren’t enabled here.");
|
|
3015
|
+
return finishMsfSignIn(fresh, { ...current, code });
|
|
3016
|
+
})
|
|
3017
|
+
.then((token) => {
|
|
3018
|
+
if (stopped) return;
|
|
3019
|
+
stopMsf();
|
|
3020
|
+
signedIn(token);
|
|
3021
|
+
})
|
|
3022
|
+
.catch(fail);
|
|
3023
|
+
});
|
|
3024
|
+
msfAttempt.current = { state: "", verifier: "", stop };
|
|
3025
|
+
setMsfWaiting(true);
|
|
3026
|
+
msfAuthorization(authorizeUrl, window.location.href)
|
|
3027
|
+
.then((request) => {
|
|
3028
|
+
if (stopped) return;
|
|
3029
|
+
attempt = { state: request.state, verifier: request.verifier };
|
|
3030
|
+
msfAttempt.current = { ...attempt, stop };
|
|
3031
|
+
if (popup && !popup.closed) popup.location.href = request.url;
|
|
3032
|
+
else if (!window.open(request.url, "_blank"))
|
|
3033
|
+
throw new Error("Allow pop-ups for this site to sign in.");
|
|
3034
|
+
})
|
|
3035
|
+
.catch((caught) => {
|
|
3036
|
+
popup?.close();
|
|
3037
|
+
fail(caught);
|
|
3038
|
+
});
|
|
3039
|
+
};
|
|
3040
|
+
|
|
3041
|
+
const disabledCard = (
|
|
3042
|
+
<section
|
|
3043
|
+
className="cs-card cs-unlock cs-hit"
|
|
3044
|
+
data-cs-ui="disabled"
|
|
3045
|
+
aria-label="Comments are off"
|
|
3046
|
+
>
|
|
3047
|
+
<div className="cs-unlock-icon">
|
|
3048
|
+
<Icon.Lock size={14} />
|
|
3049
|
+
</div>
|
|
3050
|
+
<h2>Comments are off</h2>
|
|
3051
|
+
<p className="cs-sub">
|
|
3052
|
+
Comments are turned off for this app right now. They come back here as soon as they’re on
|
|
3053
|
+
again.
|
|
3054
|
+
</p>
|
|
3055
|
+
</section>
|
|
3056
|
+
);
|
|
3057
|
+
|
|
3058
|
+
const msfButton = signIn?.msf ? (
|
|
3059
|
+
<>
|
|
3060
|
+
<button
|
|
3061
|
+
className="cs-button is-primary is-wide"
|
|
3062
|
+
data-cs-ui="msf-sign-in"
|
|
3063
|
+
type="button"
|
|
3064
|
+
disabled={busy}
|
|
3065
|
+
onClick={msfWaiting ? stopMsf : startMsf}
|
|
3066
|
+
>
|
|
3067
|
+
{msfWaiting ? "Waiting for sign-in… Cancel" : `Sign in with ${signIn.msf.label}`}
|
|
3068
|
+
</button>
|
|
3069
|
+
</>
|
|
3070
|
+
) : null;
|
|
3071
|
+
|
|
3072
|
+
const unlockCard = (
|
|
3073
|
+
<section className="cs-card cs-unlock cs-hit" data-cs-ui="panel" aria-label="Unlock comments">
|
|
3074
|
+
<div className="cs-unlock-icon">
|
|
3075
|
+
<Icon.Lock size={14} />
|
|
3076
|
+
</div>
|
|
3077
|
+
<h2>Comments</h2>
|
|
3078
|
+
<p className="cs-sub">
|
|
3079
|
+
{invite
|
|
3080
|
+
? "Invite links don’t work here any more. Sign in to see and add comments."
|
|
3081
|
+
: signIn?.msf
|
|
3082
|
+
? "Sign in to see and add comments."
|
|
3083
|
+
: signIn && !signIn.unavailable
|
|
3084
|
+
? // The project has nowhere to sign in yet; naming a provider here would send people looking for one.
|
|
3085
|
+
"Sign-in isn’t set up for this project yet. Ask whoever added comments to this app."
|
|
3086
|
+
: "Sign in to see and add comments."}
|
|
3087
|
+
</p>
|
|
3088
|
+
{signIn?.msf ? <div className="cs-unlock-sso">{msfButton}</div> : null}
|
|
3089
|
+
{signIn?.unavailable ? (
|
|
3090
|
+
<p className="cs-error" data-cs-ui="error">
|
|
3091
|
+
{wrongOrigin
|
|
3092
|
+
? tokenForOtherOrigin(wrongOrigin)
|
|
3093
|
+
: "Couldn’t check how to sign in. Reload and try again."}
|
|
3094
|
+
</p>
|
|
3095
|
+
) : !signIn ? (
|
|
3096
|
+
<p className="cs-sub">Checking sign-in…</p>
|
|
3097
|
+
) : error ? (
|
|
3098
|
+
<p className="cs-error" data-cs-ui="error">
|
|
3099
|
+
{error}
|
|
3100
|
+
</p>
|
|
3101
|
+
) : null}
|
|
3102
|
+
</section>
|
|
3103
|
+
);
|
|
3104
|
+
|
|
3105
|
+
const resetConfirm = (
|
|
3106
|
+
<>
|
|
3107
|
+
<div className="cs-confirm" data-cs-ui="reset-confirm">
|
|
3108
|
+
<h2>Logout?</h2>
|
|
3109
|
+
<p className="cs-sub">
|
|
3110
|
+
You’ll be signed out and your name and preferences are cleared. Comments aren’t affected.
|
|
3111
|
+
</p>
|
|
3112
|
+
<div className="cs-row-end">
|
|
3113
|
+
<button className="cs-button" type="button" onClick={() => setConfirmReset(false)}>
|
|
3114
|
+
Cancel
|
|
3115
|
+
</button>
|
|
3116
|
+
<button
|
|
3117
|
+
className="cs-button is-danger"
|
|
3118
|
+
data-cs-ui="reset-submit"
|
|
3119
|
+
type="button"
|
|
3120
|
+
onClick={reset}
|
|
3121
|
+
>
|
|
3122
|
+
Logout
|
|
3123
|
+
</button>
|
|
3124
|
+
</div>
|
|
3125
|
+
</div>
|
|
3126
|
+
</>
|
|
3127
|
+
);
|
|
3128
|
+
const meRow = (
|
|
3129
|
+
<>
|
|
3130
|
+
<div className="cs-me-row">
|
|
3131
|
+
<Avatar name={renaming ?? memberName} size={27} />
|
|
3132
|
+
{renaming !== null ? (
|
|
3133
|
+
<form
|
|
3134
|
+
className="cs-rename"
|
|
3135
|
+
onSubmit={(event) => {
|
|
3136
|
+
event.preventDefault();
|
|
3137
|
+
const next = renaming.trim();
|
|
3138
|
+
if (!next || next === memberName || !member) {
|
|
3139
|
+
setRenaming(null);
|
|
3140
|
+
return;
|
|
3141
|
+
}
|
|
3142
|
+
setBusy(true);
|
|
3143
|
+
updateProfile(session, member, { displayName: next })
|
|
3144
|
+
.then((updated) => {
|
|
3145
|
+
localStorage.setItem(storageKey(session), updated.token);
|
|
3146
|
+
setMember(updated.token);
|
|
3147
|
+
setProfile(updated);
|
|
3148
|
+
setName(next);
|
|
3149
|
+
setRenaming(null);
|
|
3150
|
+
say("Name updated");
|
|
3151
|
+
})
|
|
3152
|
+
.catch((caught) =>
|
|
3153
|
+
setError(caught instanceof Error ? caught.message : "Couldn’t rename."),
|
|
3154
|
+
)
|
|
3155
|
+
.finally(() => setBusy(false));
|
|
3156
|
+
}}
|
|
3157
|
+
>
|
|
3158
|
+
<input
|
|
3159
|
+
data-cs-ui="rename-input"
|
|
3160
|
+
aria-label="Your name"
|
|
3161
|
+
value={renaming}
|
|
3162
|
+
maxLength={40}
|
|
3163
|
+
autoFocus
|
|
3164
|
+
onChange={(event) => setRenaming(event.target.value)}
|
|
3165
|
+
onKeyDown={(event) => {
|
|
3166
|
+
if (event.key === "Escape") {
|
|
3167
|
+
event.preventDefault();
|
|
3168
|
+
setRenaming(null);
|
|
3169
|
+
}
|
|
3170
|
+
}}
|
|
3171
|
+
/>
|
|
3172
|
+
<button className="cs-icon-button" type="submit" aria-label="Save name" disabled={busy}>
|
|
3173
|
+
<Icon.Check />
|
|
3174
|
+
</button>
|
|
3175
|
+
</form>
|
|
3176
|
+
) : (
|
|
3177
|
+
<div className="cs-me-name">
|
|
3178
|
+
<span className="cs-me-lines">
|
|
3179
|
+
<strong>{memberName}</strong>
|
|
3180
|
+
<small>{profile?.email ?? readJwt(member ?? "")?.email ?? ""}</small>
|
|
3181
|
+
</span>
|
|
3182
|
+
<button
|
|
3183
|
+
className="cs-icon-button"
|
|
3184
|
+
data-cs-ui="rename"
|
|
3185
|
+
type="button"
|
|
3186
|
+
aria-label="Change name"
|
|
3187
|
+
onClick={() => {
|
|
3188
|
+
setError("");
|
|
3189
|
+
setRenaming(memberName);
|
|
3190
|
+
}}
|
|
3191
|
+
>
|
|
3192
|
+
<Icon.Pencil />
|
|
3193
|
+
</button>
|
|
3194
|
+
</div>
|
|
3195
|
+
)}
|
|
3196
|
+
</div>
|
|
3197
|
+
</>
|
|
3198
|
+
);
|
|
3199
|
+
const emailSwitch = (
|
|
3200
|
+
<>
|
|
3201
|
+
<button
|
|
3202
|
+
className="cs-item"
|
|
3203
|
+
data-cs-ui="mention-emails"
|
|
3204
|
+
type="button"
|
|
3205
|
+
role="switch"
|
|
3206
|
+
aria-checked={profile?.mentionEmails ?? true}
|
|
3207
|
+
disabled={!profile}
|
|
3208
|
+
onClick={() => {
|
|
3209
|
+
if (!member || !profile) return;
|
|
3210
|
+
updateProfile(session, member, { mentionEmails: !profile.mentionEmails })
|
|
3211
|
+
.then((updated) => {
|
|
3212
|
+
localStorage.setItem(storageKey(session), updated.token);
|
|
3213
|
+
setMember(updated.token);
|
|
3214
|
+
setProfile(updated);
|
|
3215
|
+
say(updated.mentionEmails ? "Mention emails on" : "Mention emails off");
|
|
3216
|
+
})
|
|
3217
|
+
.catch((caught) =>
|
|
3218
|
+
setError(caught instanceof Error ? caught.message : "Couldn’t update."),
|
|
3219
|
+
);
|
|
3220
|
+
}}
|
|
3221
|
+
>
|
|
3222
|
+
<span>Email me when mentioned</span>
|
|
3223
|
+
<span className={(profile?.mentionEmails ?? true) ? "cs-switch is-on" : "cs-switch"} />
|
|
3224
|
+
</button>
|
|
3225
|
+
</>
|
|
3226
|
+
);
|
|
3227
|
+
const pinsSwitch = (
|
|
3228
|
+
<>
|
|
3229
|
+
<button
|
|
3230
|
+
className="cs-item"
|
|
3231
|
+
type="button"
|
|
3232
|
+
role="switch"
|
|
3233
|
+
aria-checked={!pinsHidden}
|
|
3234
|
+
onClick={togglePins}
|
|
3235
|
+
>
|
|
3236
|
+
<span>Show pins</span>
|
|
3237
|
+
<span className={pinsHidden ? "cs-switch" : "cs-switch is-on"} />
|
|
3238
|
+
</button>
|
|
3239
|
+
</>
|
|
3240
|
+
);
|
|
3241
|
+
const positionRow = (
|
|
3242
|
+
<>
|
|
3243
|
+
<div className="cs-item is-static">
|
|
3244
|
+
<span>Position</span>
|
|
3245
|
+
<span className="cs-segment" role="radiogroup" aria-label="Toolbar position">
|
|
3246
|
+
{positions.map((option) => (
|
|
3247
|
+
<button
|
|
3248
|
+
key={option.value}
|
|
3249
|
+
type="button"
|
|
3250
|
+
role="radio"
|
|
3251
|
+
aria-checked={!prefs.dock && barPosition === option.value}
|
|
3252
|
+
className={!prefs.dock && barPosition === option.value ? "is-on" : ""}
|
|
3253
|
+
onClick={() => {
|
|
3254
|
+
const next = { ...prefs, position: option.value };
|
|
3255
|
+
delete next.dock;
|
|
3256
|
+
savePrefs(next);
|
|
3257
|
+
}}
|
|
3258
|
+
>
|
|
3259
|
+
{option.label}
|
|
3260
|
+
</button>
|
|
3261
|
+
))}
|
|
3262
|
+
</span>
|
|
3263
|
+
</div>
|
|
3264
|
+
<p className="cs-hint">Drag the toolbar to move it.</p>
|
|
3265
|
+
</>
|
|
3266
|
+
);
|
|
3267
|
+
const lockButtons = (
|
|
3268
|
+
<>
|
|
3269
|
+
<button className="cs-item" data-cs-ui="lock" type="button" onClick={lock}>
|
|
3270
|
+
<span>Lock</span>
|
|
3271
|
+
</button>
|
|
3272
|
+
<button
|
|
3273
|
+
className="cs-item is-danger"
|
|
3274
|
+
data-cs-ui="reset"
|
|
3275
|
+
type="button"
|
|
3276
|
+
onClick={() => setConfirmReset(true)}
|
|
3277
|
+
>
|
|
3278
|
+
<span>Logout</span>
|
|
3279
|
+
</button>
|
|
3280
|
+
</>
|
|
3281
|
+
);
|
|
3282
|
+
const profilePane = confirmReset ? (
|
|
3283
|
+
resetConfirm
|
|
3284
|
+
) : (
|
|
3285
|
+
<>
|
|
3286
|
+
{meRow}
|
|
3287
|
+
{error ? <p className="cs-error">{error}</p> : null}
|
|
3288
|
+
<div className="cs-sep" />
|
|
3289
|
+
{emailSwitch}
|
|
3290
|
+
<div className="cs-sep" />
|
|
3291
|
+
{lockButtons}
|
|
3292
|
+
</>
|
|
3293
|
+
);
|
|
3294
|
+
|
|
3295
|
+
const settingsPane = (
|
|
3296
|
+
<>
|
|
3297
|
+
{pinsSwitch}
|
|
3298
|
+
{positionRow}
|
|
3299
|
+
<div className="cs-sep" />
|
|
3300
|
+
<SettingsPanel settings={settings} onChange={changeSettings} error={error} />
|
|
3301
|
+
<p className="cs-hint">S snapshot · H pins</p>
|
|
3302
|
+
</>
|
|
3303
|
+
);
|
|
3304
|
+
|
|
3305
|
+
const envChips = linked.length ? (
|
|
3306
|
+
<div
|
|
3307
|
+
className="cs-env-filter"
|
|
3308
|
+
data-cs-ui="env-filter"
|
|
3309
|
+
role="radiogroup"
|
|
3310
|
+
aria-label="Environment"
|
|
3311
|
+
>
|
|
3312
|
+
{[
|
|
3313
|
+
["all", "Everywhere"],
|
|
3314
|
+
[session.environment, `${session.environment} (here)`],
|
|
3315
|
+
...linked.map((name) => [name, name]),
|
|
3316
|
+
].map(([value, label]) => (
|
|
3317
|
+
<button
|
|
3318
|
+
key={value}
|
|
3319
|
+
type="button"
|
|
3320
|
+
role="radio"
|
|
3321
|
+
aria-checked={envScope === value}
|
|
3322
|
+
data-env={value}
|
|
3323
|
+
className={envScope === value ? "is-on" : ""}
|
|
3324
|
+
onClick={() => setEnvFilter(value)}
|
|
3325
|
+
>
|
|
3326
|
+
{label}
|
|
3327
|
+
</button>
|
|
3328
|
+
))}
|
|
3329
|
+
{live ? (
|
|
3330
|
+
<span className="cs-live" data-cs-ui="live" title="Updates arrive as they happen">
|
|
3331
|
+
Live
|
|
3332
|
+
</span>
|
|
3333
|
+
) : null}
|
|
3334
|
+
</div>
|
|
3335
|
+
) : null;
|
|
3336
|
+
const envBadge = (thread: Thread) => {
|
|
3337
|
+
const environment = linkedEnv(thread);
|
|
3338
|
+
return environment ? (
|
|
3339
|
+
<span className="cs-env" data-cs-ui="env-badge">
|
|
3340
|
+
{environment}
|
|
3341
|
+
</span>
|
|
3342
|
+
) : null;
|
|
3343
|
+
};
|
|
3344
|
+
|
|
3345
|
+
const replyComposer = (thread: Thread) => {
|
|
3346
|
+
if (readOnly) return null;
|
|
3347
|
+
const foreign = linkedEnv(thread);
|
|
3348
|
+
const replyMembers = foreign ? (foreign === replyEnv ? replyEnvMembers : []) : ownMembers;
|
|
3349
|
+
const owns = replyId === thread.id;
|
|
3350
|
+
return (
|
|
3351
|
+
<>
|
|
3352
|
+
<form
|
|
3353
|
+
className="cs-reply"
|
|
3354
|
+
onPointerDownCapture={() => claimReply(thread.id)}
|
|
3355
|
+
onFocusCapture={() => claimReply(thread.id)}
|
|
3356
|
+
// The page shield stops presses before they reach here and keeps buttons from taking focus, so a
|
|
3357
|
+
// press on the paperclip (or any button of the box) would otherwise act on a box nobody claimed:
|
|
3358
|
+
// the file uploaded, never showed, and was dropped on the first keystroke.
|
|
3359
|
+
onClickCapture={() => claimReply(thread.id)}
|
|
3360
|
+
onSubmit={async (event) => {
|
|
3361
|
+
event.preventDefault();
|
|
3362
|
+
if (
|
|
3363
|
+
!member ||
|
|
3364
|
+
!owns ||
|
|
3365
|
+
(!reply.trim() && !replyFiles.ids.length) ||
|
|
3366
|
+
replyFiles.uploading ||
|
|
3367
|
+
marking ||
|
|
3368
|
+
capturing ||
|
|
3369
|
+
recording.phase !== "idle" ||
|
|
3370
|
+
replyDictation.busy
|
|
3371
|
+
)
|
|
3372
|
+
return;
|
|
3373
|
+
setBusy(true);
|
|
3374
|
+
const picked = mentionsIn(reply, replyPicked.current);
|
|
3375
|
+
const element = elementOf(thread);
|
|
3376
|
+
const screenshot =
|
|
3377
|
+
picked.length && element
|
|
3378
|
+
? await (replyShot.current ?? cropReply(element, thread.anchor.sketch)).catch(
|
|
3379
|
+
() => null,
|
|
3380
|
+
)
|
|
3381
|
+
: null;
|
|
3382
|
+
replyToThread(session, member, thread.id, reply, {
|
|
3383
|
+
mentions: picked.map((person) => person.id),
|
|
3384
|
+
screenshot,
|
|
3385
|
+
attachments: replyFiles.ids,
|
|
3386
|
+
})
|
|
3387
|
+
.then(async (result) => {
|
|
3388
|
+
setReply("");
|
|
3389
|
+
replyFiles.reset();
|
|
3390
|
+
setError("");
|
|
3391
|
+
replyPicked.current = new Map();
|
|
3392
|
+
replyShot.current = null;
|
|
3393
|
+
replyPage.current = null;
|
|
3394
|
+
replySnapped.current = false;
|
|
3395
|
+
if (result.delivery) {
|
|
3396
|
+
setDeliveries((current) => ({
|
|
3397
|
+
...current,
|
|
3398
|
+
[result.message.id]: result.delivery!,
|
|
3399
|
+
}));
|
|
3400
|
+
const note = deliveryText(result.delivery);
|
|
3401
|
+
if (note) say(note);
|
|
3402
|
+
}
|
|
3403
|
+
await refresh();
|
|
3404
|
+
})
|
|
3405
|
+
.catch((caught) =>
|
|
3406
|
+
setError(caught instanceof Error ? caught.message : "Couldn’t reply."),
|
|
3407
|
+
)
|
|
3408
|
+
.finally(() => setBusy(false));
|
|
3409
|
+
}}
|
|
3410
|
+
>
|
|
3411
|
+
<MentionTextarea
|
|
3412
|
+
data-cs-ui="reply-body"
|
|
3413
|
+
aria-label="Reply"
|
|
3414
|
+
placeholder="Reply… (@ to mention)"
|
|
3415
|
+
rows={1}
|
|
3416
|
+
value={owns ? reply : ""}
|
|
3417
|
+
onValueChange={(value) => {
|
|
3418
|
+
if (!owns) {
|
|
3419
|
+
claimReply(thread.id);
|
|
3420
|
+
setReply(value);
|
|
3421
|
+
return;
|
|
3422
|
+
}
|
|
3423
|
+
setReply(value);
|
|
3424
|
+
if (!value.trim() || replySnapped.current) return;
|
|
3425
|
+
replySnapped.current = true;
|
|
3426
|
+
const element = elementOf(thread);
|
|
3427
|
+
if (element) scheduleSnapshot("reply", element, replyFiles.add, thread.anchor.sketch);
|
|
3428
|
+
}}
|
|
3429
|
+
members={replyMembers}
|
|
3430
|
+
selfEmail={readJwt(member ?? "")?.email}
|
|
3431
|
+
onMention={(person) => {
|
|
3432
|
+
replyPicked.current.set(person.id, person);
|
|
3433
|
+
const element = elementOf(thread);
|
|
3434
|
+
if (element && !replyShot.current)
|
|
3435
|
+
replyShot.current = cropReply(element, thread.anchor.sketch);
|
|
3436
|
+
}}
|
|
3437
|
+
onKeyDown={(event) => {
|
|
3438
|
+
if (isDictationKey(event)) {
|
|
3439
|
+
event.preventDefault();
|
|
3440
|
+
replyDictation.toggle(event.currentTarget);
|
|
3441
|
+
return;
|
|
3442
|
+
}
|
|
3443
|
+
submitOnCmdEnter(event);
|
|
3444
|
+
}}
|
|
3445
|
+
onPaste={(event) => {
|
|
3446
|
+
const files = filesFromClipboard(event);
|
|
3447
|
+
if (files.length) {
|
|
3448
|
+
event.preventDefault();
|
|
3449
|
+
replyFiles.add(files);
|
|
3450
|
+
}
|
|
3451
|
+
}}
|
|
3452
|
+
/>
|
|
3453
|
+
{replyDictation.shown && owns ? null : (
|
|
3454
|
+
<AttachButtons
|
|
3455
|
+
onFiles={replyFiles.add}
|
|
3456
|
+
capturing={capturing || recording.phase !== "idle"}
|
|
3457
|
+
onSnapshot={() =>
|
|
3458
|
+
snapshot(takeShot(elementOf(thread)), replyFiles.add, thread.anchor.sketch)
|
|
3459
|
+
}
|
|
3460
|
+
onRecord={() => {
|
|
3461
|
+
setRecordInto("reply");
|
|
3462
|
+
void recording.begin(replyFiles.add);
|
|
3463
|
+
}}
|
|
3464
|
+
onReplay={() => attachReplay(replyFiles.add)}
|
|
3465
|
+
/>
|
|
3466
|
+
)}
|
|
3467
|
+
{owns || !replyDictation.shown ? (
|
|
3468
|
+
<DictateButton
|
|
3469
|
+
dictation={replyDictation}
|
|
3470
|
+
field="reply-body"
|
|
3471
|
+
disabled={busy || recording.phase !== "idle"}
|
|
3472
|
+
compact
|
|
3473
|
+
/>
|
|
3474
|
+
) : null}
|
|
3475
|
+
<button
|
|
3476
|
+
className="cs-send"
|
|
3477
|
+
data-cs-ui="reply-submit"
|
|
3478
|
+
type="submit"
|
|
3479
|
+
aria-label="Post reply"
|
|
3480
|
+
disabled={
|
|
3481
|
+
busy ||
|
|
3482
|
+
capturing ||
|
|
3483
|
+
recording.phase !== "idle" ||
|
|
3484
|
+
!owns ||
|
|
3485
|
+
replyFiles.uploading ||
|
|
3486
|
+
marking ||
|
|
3487
|
+
(owns && replyDictation.busy) ||
|
|
3488
|
+
(!reply.trim() && !replyFiles.ids.length)
|
|
3489
|
+
}
|
|
3490
|
+
>
|
|
3491
|
+
<Icon.Send />
|
|
3492
|
+
</button>
|
|
3493
|
+
</form>
|
|
3494
|
+
{owns ? (
|
|
3495
|
+
<AttachmentTray
|
|
3496
|
+
items={replyFiles.items}
|
|
3497
|
+
activeKey={trayKey}
|
|
3498
|
+
onPreview={(item) => {
|
|
3499
|
+
setPreview(null);
|
|
3500
|
+
setTrayKey((current) => (current === item.key ? null : item.key));
|
|
3501
|
+
}}
|
|
3502
|
+
onRemove={replyFiles.remove}
|
|
3503
|
+
/>
|
|
3504
|
+
) : null}
|
|
3505
|
+
</>
|
|
3506
|
+
);
|
|
3507
|
+
};
|
|
3508
|
+
|
|
3509
|
+
const openPreview = (thread: Thread, file: { id: string }) => {
|
|
3510
|
+
setTrayKey(null);
|
|
3511
|
+
setPreview((current) =>
|
|
3512
|
+
current?.threadId === thread.id && current.fileId === file.id
|
|
3513
|
+
? null
|
|
3514
|
+
: { threadId: thread.id, fileId: file.id },
|
|
3515
|
+
);
|
|
3516
|
+
};
|
|
3517
|
+
const trayFiles = pendingPreviews(draft ? draftFiles.items : replyFiles.items);
|
|
3518
|
+
const trayPane =
|
|
3519
|
+
trayKey && trayFiles.some((file) => file.id === trayKey) ? (
|
|
3520
|
+
<MediaPane
|
|
3521
|
+
files={trayFiles}
|
|
3522
|
+
fileId={trayKey}
|
|
3523
|
+
onSelect={setTrayKey}
|
|
3524
|
+
onClose={() => setTrayKey(null)}
|
|
3525
|
+
onMarkup={(fileId, result) => {
|
|
3526
|
+
const next = (draft ? draftFiles : replyFiles).replace(fileId, result);
|
|
3527
|
+
setTrayKey((current) => (current === fileId ? next : current));
|
|
3528
|
+
}}
|
|
3529
|
+
onMarkingUp={(open) => {
|
|
3530
|
+
markingUp.current = open;
|
|
3531
|
+
setMarking(open);
|
|
3532
|
+
}}
|
|
3533
|
+
/>
|
|
3534
|
+
) : null;
|
|
3535
|
+
const previewFiles = visualAttachments(
|
|
3536
|
+
threads.find((thread) => thread.id === preview?.threadId)?.messages,
|
|
3537
|
+
);
|
|
3538
|
+
const previewPane =
|
|
3539
|
+
preview && previewFiles.some((file) => file.id === preview.fileId) ? (
|
|
3540
|
+
<MediaPane
|
|
3541
|
+
files={previewFiles}
|
|
3542
|
+
fileId={preview.fileId}
|
|
3543
|
+
onSelect={(fileId) => setPreview({ threadId: preview.threadId, fileId })}
|
|
3544
|
+
onClose={() => setPreview(null)}
|
|
3545
|
+
/>
|
|
3546
|
+
) : null;
|
|
3547
|
+
const activeFile = (threadId: string) => (preview?.threadId === threadId ? preview.fileId : null);
|
|
3548
|
+
|
|
3549
|
+
const focusThread = focusItem?.thread;
|
|
3550
|
+
const focusCard =
|
|
3551
|
+
focusAt && focusItem && focusThread ? (
|
|
3552
|
+
<PinCard
|
|
3553
|
+
x={focusAt.x}
|
|
3554
|
+
y={focusAt.y}
|
|
3555
|
+
bounds={bounds}
|
|
3556
|
+
label={`Comment by ${focusThread.createdBy}`}
|
|
3557
|
+
preview={
|
|
3558
|
+
trayPane && !draft ? trayPane : preview?.threadId === focusThread.id ? previewPane : null
|
|
3559
|
+
}
|
|
3560
|
+
>
|
|
3561
|
+
{(() => {
|
|
3562
|
+
const thread = focusThread;
|
|
3563
|
+
const first = thread.messages[0];
|
|
3564
|
+
const foreign = linkedEnv(thread);
|
|
3565
|
+
const onPage = focusItem.status === "pinned" || focusItem.status === "drifted";
|
|
3566
|
+
const note = statusNote(focusItem.status, thread.anchor);
|
|
3567
|
+
const ring =
|
|
3568
|
+
thread.status === "resolved"
|
|
3569
|
+
? "is-done"
|
|
3570
|
+
: focusItem.status === "drifted"
|
|
3571
|
+
? "is-drifted"
|
|
3572
|
+
: foreign
|
|
3573
|
+
? "is-linked"
|
|
3574
|
+
: "is-open";
|
|
3575
|
+
return (
|
|
3576
|
+
<article
|
|
3577
|
+
className="cs-focus-thread"
|
|
3578
|
+
data-cs-ui="thread"
|
|
3579
|
+
data-thread-id={thread.id}
|
|
3580
|
+
data-cs-status={focusItem.status}
|
|
3581
|
+
data-cs-env={thread.environment ?? session.environment}
|
|
3582
|
+
>
|
|
3583
|
+
<header className="cs-focus-head">
|
|
3584
|
+
<span className={`cs-focus-ring ${ring}`}>
|
|
3585
|
+
<Avatar name={thread.createdBy} size={21} />
|
|
3586
|
+
</span>
|
|
3587
|
+
<div className="cs-focus-who">
|
|
3588
|
+
<strong>{thread.createdBy}</strong>
|
|
3589
|
+
<time dateTime={thread.createdAt}>{ago(thread.createdAt)}</time>
|
|
3590
|
+
{envBadge(thread)}
|
|
3591
|
+
<RoleBadge role={thread.role} />
|
|
3592
|
+
<TicketBadge ticket={thread.ticket} />
|
|
3593
|
+
</div>
|
|
3594
|
+
<div className="cs-focus-tools">
|
|
3595
|
+
<button
|
|
3596
|
+
className="cs-icon-button"
|
|
3597
|
+
type="button"
|
|
3598
|
+
aria-label="Thread actions"
|
|
3599
|
+
aria-expanded={focusMenu}
|
|
3600
|
+
onClick={() => setFocusMenu((open) => !open)}
|
|
3601
|
+
>
|
|
3602
|
+
<Icon.Ellipsis />
|
|
3603
|
+
</button>
|
|
3604
|
+
{foreign || readOnly ? null : (
|
|
3605
|
+
<button
|
|
3606
|
+
className={
|
|
3607
|
+
thread.status === "resolved" ? "cs-icon-button is-done" : "cs-icon-button"
|
|
3608
|
+
}
|
|
3609
|
+
data-cs-ui="resolve"
|
|
3610
|
+
type="button"
|
|
3611
|
+
aria-label={thread.status === "resolved" ? "Reopen" : "Resolve"}
|
|
3612
|
+
onClick={() =>
|
|
3613
|
+
changeStatus(thread, thread.status === "resolved" ? "open" : "resolved")
|
|
3614
|
+
}
|
|
3615
|
+
>
|
|
3616
|
+
{thread.status === "resolved" ? <Icon.Undo /> : <Icon.Check />}
|
|
3617
|
+
</button>
|
|
3618
|
+
)}
|
|
3619
|
+
<button
|
|
3620
|
+
className="cs-icon-button"
|
|
3621
|
+
type="button"
|
|
3622
|
+
aria-label="Close thread"
|
|
3623
|
+
onClick={() => {
|
|
3624
|
+
setFocusId(null);
|
|
3625
|
+
setFocusMenu(false);
|
|
3626
|
+
}}
|
|
3627
|
+
>
|
|
3628
|
+
<Icon.Close />
|
|
3629
|
+
</button>
|
|
3630
|
+
</div>
|
|
3631
|
+
</header>
|
|
3632
|
+
{focusMenu ? (
|
|
3633
|
+
<div className="cs-focus-menu" role="menu">
|
|
3634
|
+
{onPage && thread.status === "open" ? (
|
|
3635
|
+
<button
|
|
3636
|
+
className="cs-item"
|
|
3637
|
+
type="button"
|
|
3638
|
+
role="menuitem"
|
|
3639
|
+
onClick={() => {
|
|
3640
|
+
showOnPage(thread);
|
|
3641
|
+
setFocusMenu(false);
|
|
3642
|
+
}}
|
|
3643
|
+
>
|
|
3644
|
+
<span>Show on page</span>
|
|
3645
|
+
</button>
|
|
3646
|
+
) : null}
|
|
3647
|
+
{!foreign && !readOnly && detailsEnabled(settings) && editingId !== thread.id ? (
|
|
3648
|
+
<button
|
|
3649
|
+
className="cs-item"
|
|
3650
|
+
data-cs-ui="details-edit"
|
|
3651
|
+
type="button"
|
|
3652
|
+
role="menuitem"
|
|
3653
|
+
onClick={() => {
|
|
3654
|
+
setEditingId(thread.id);
|
|
3655
|
+
setFocusMenu(false);
|
|
3656
|
+
}}
|
|
3657
|
+
>
|
|
3658
|
+
<span>Details</span>
|
|
3659
|
+
</button>
|
|
3660
|
+
) : null}
|
|
3661
|
+
{!foreign &&
|
|
3662
|
+
!readOnly &&
|
|
3663
|
+
!onPage &&
|
|
3664
|
+
thread.status === "open" &&
|
|
3665
|
+
here.has(thread) ? (
|
|
3666
|
+
<button
|
|
3667
|
+
className="cs-item"
|
|
3668
|
+
data-cs-ui="reattach"
|
|
3669
|
+
type="button"
|
|
3670
|
+
role="menuitem"
|
|
3671
|
+
onClick={() => {
|
|
3672
|
+
setReattachId(thread.id);
|
|
3673
|
+
setMode("reattach");
|
|
3674
|
+
setFocusId(null);
|
|
3675
|
+
setFocusMenu(false);
|
|
3676
|
+
setSurface("none");
|
|
3677
|
+
}}
|
|
3678
|
+
>
|
|
3679
|
+
<span>Reattach</span>
|
|
3680
|
+
</button>
|
|
3681
|
+
) : null}
|
|
3682
|
+
<button
|
|
3683
|
+
className="cs-item"
|
|
3684
|
+
type="button"
|
|
3685
|
+
role="menuitem"
|
|
3686
|
+
onClick={() => openComments(thread.id)}
|
|
3687
|
+
>
|
|
3688
|
+
<span>All comments</span>
|
|
3689
|
+
</button>
|
|
3690
|
+
</div>
|
|
3691
|
+
) : null}
|
|
3692
|
+
<div className="cs-focus-scroll" onPointerDown={() => setFocusMenu(false)}>
|
|
3693
|
+
<div className="cs-focus-body">
|
|
3694
|
+
{first && editingMessageId === first.id ? (
|
|
3695
|
+
messageEditor(thread, first, 0)
|
|
3696
|
+
) : first?.body ? (
|
|
3697
|
+
<p className="cs-text">
|
|
3698
|
+
<MessageText body={first.body} mentions={first.mentions} />
|
|
3699
|
+
</p>
|
|
3700
|
+
) : null}
|
|
3701
|
+
{first && editingMessageId !== first.id ? (
|
|
3702
|
+
<div className="cs-first-tools">{messageTools(thread, first)}</div>
|
|
3703
|
+
) : null}
|
|
3704
|
+
{first && historyId === first.id ? <MessageHistory message={first} /> : null}
|
|
3705
|
+
<FieldValues fields={thread.fields} />
|
|
3706
|
+
<MessageAttachments
|
|
3707
|
+
attachments={first?.attachments}
|
|
3708
|
+
activeId={activeFile(thread.id)}
|
|
3709
|
+
onPreview={(file) => openPreview(thread, file)}
|
|
3710
|
+
/>
|
|
3711
|
+
{first && deliveryText(deliveries[first.id]) ? (
|
|
3712
|
+
<div className="cs-delivery" data-cs-ui="delivery">
|
|
3713
|
+
{deliveryText(deliveries[first.id])}
|
|
3714
|
+
</div>
|
|
3715
|
+
) : null}
|
|
3716
|
+
<div className="cs-target">
|
|
3717
|
+
<span title={thread.anchor.component?.file ?? undefined}>
|
|
3718
|
+
{targetName(thread.anchor)}
|
|
3719
|
+
</span>
|
|
3720
|
+
{note ? <span className={`cs-note is-${note.tone}`}>{note.text}</span> : null}
|
|
3721
|
+
</div>
|
|
3722
|
+
</div>
|
|
3723
|
+
{thread.messages.length > 1 ? (
|
|
3724
|
+
<div className="cs-focus-replies">
|
|
3725
|
+
{thread.messages.slice(1).map((message, index) => (
|
|
3726
|
+
<div
|
|
3727
|
+
key={message.id}
|
|
3728
|
+
className="cs-message"
|
|
3729
|
+
data-cs-ui="message"
|
|
3730
|
+
data-message-id={message.id}
|
|
3731
|
+
>
|
|
3732
|
+
<Avatar name={message.author} size={17} />
|
|
3733
|
+
<div className="cs-message-body">
|
|
3734
|
+
<div className="cs-meta">
|
|
3735
|
+
<strong>{message.author}</strong>
|
|
3736
|
+
<time dateTime={message.createdAt}>{ago(message.createdAt)}</time>
|
|
3737
|
+
{editingMessageId !== message.id ? messageTools(thread, message) : null}
|
|
3738
|
+
</div>
|
|
3739
|
+
{editingMessageId === message.id ? (
|
|
3740
|
+
messageEditor(thread, message, index + 1)
|
|
3741
|
+
) : message.body ? (
|
|
3742
|
+
<p className="cs-text">
|
|
3743
|
+
<MessageText body={message.body} mentions={message.mentions} />
|
|
3744
|
+
</p>
|
|
3745
|
+
) : null}
|
|
3746
|
+
{historyId === message.id ? <MessageHistory message={message} /> : null}
|
|
3747
|
+
<MessageAttachments
|
|
3748
|
+
attachments={message.attachments}
|
|
3749
|
+
activeId={activeFile(thread.id)}
|
|
3750
|
+
onPreview={(file) => openPreview(thread, file)}
|
|
3751
|
+
/>
|
|
3752
|
+
{deliveryText(deliveries[message.id]) ? (
|
|
3753
|
+
<div className="cs-delivery" data-cs-ui="delivery">
|
|
3754
|
+
{deliveryText(deliveries[message.id])}
|
|
3755
|
+
</div>
|
|
3756
|
+
) : null}
|
|
3757
|
+
</div>
|
|
3758
|
+
</div>
|
|
3759
|
+
))}
|
|
3760
|
+
</div>
|
|
3761
|
+
) : null}
|
|
3762
|
+
{editingId === thread.id && settings ? (
|
|
3763
|
+
<DetailsEditor
|
|
3764
|
+
settings={settings}
|
|
3765
|
+
thread={thread}
|
|
3766
|
+
onClose={() => setEditingId(null)}
|
|
3767
|
+
onSave={(patch) =>
|
|
3768
|
+
updateThread(session, member!, thread.id, patch)
|
|
3769
|
+
.then(() => refresh())
|
|
3770
|
+
.then(() => say("Details saved"))
|
|
3771
|
+
.catch((caught) =>
|
|
3772
|
+
setError(caught instanceof Error ? caught.message : "Couldn’t save."),
|
|
3773
|
+
)
|
|
3774
|
+
}
|
|
3775
|
+
/>
|
|
3776
|
+
) : null}
|
|
3777
|
+
{foreign ? (
|
|
3778
|
+
<p className="cs-hint cs-linked-note" data-cs-ui="linked-note">
|
|
3779
|
+
{thread.status === "resolved" ? "Resolved" : "Resolve"} in {foreign}
|
|
3780
|
+
</p>
|
|
3781
|
+
) : null}
|
|
3782
|
+
{error ? (
|
|
3783
|
+
<p className="cs-error" data-cs-ui="error">
|
|
3784
|
+
{error}
|
|
3785
|
+
</p>
|
|
3786
|
+
) : null}
|
|
3787
|
+
</div>
|
|
3788
|
+
<div className="cs-focus-foot" onPointerDown={() => setFocusMenu(false)}>
|
|
3789
|
+
{replyComposer(thread)}
|
|
3790
|
+
</div>
|
|
3791
|
+
</article>
|
|
3792
|
+
);
|
|
3793
|
+
})()}
|
|
3794
|
+
</PinCard>
|
|
3795
|
+
) : null;
|
|
3796
|
+
|
|
3797
|
+
const ui = (
|
|
3798
|
+
<div
|
|
3799
|
+
id="cs-root"
|
|
3800
|
+
data-react-grab-ignore=""
|
|
3801
|
+
className={[
|
|
3802
|
+
inside ? "cs-inside" : "cs-fixed",
|
|
3803
|
+
`cs-at-${barPosition.replace("bottom-", "")}`,
|
|
3804
|
+
theme === "dark" || (theme === "system" && systemDark) ? "cs-dark" : "",
|
|
3805
|
+
recording.phase !== "idle" ? "is-recording" : "",
|
|
3806
|
+
barFrame ? "cs-docked" : "",
|
|
3807
|
+
].join(" ")}
|
|
3808
|
+
data-edge={barFrame?.edge}
|
|
3809
|
+
data-side={barFrame?.side}
|
|
3810
|
+
data-cs-screen={screen === "desktop" ? undefined : screen}
|
|
3811
|
+
style={
|
|
3812
|
+
barFrame
|
|
3813
|
+
? ({
|
|
3814
|
+
"--cs-dock-x": `${barFrame.x}px`,
|
|
3815
|
+
"--cs-dock-y": `${barFrame.y}px`,
|
|
3816
|
+
"--cs-dock-w": `${barFrame.width}px`,
|
|
3817
|
+
"--cs-dock-h": `${barFrame.height}px`,
|
|
3818
|
+
} as CSSProperties)
|
|
3819
|
+
: undefined
|
|
3820
|
+
}
|
|
3821
|
+
>
|
|
3822
|
+
{recording.phase !== "idle" ? (
|
|
3823
|
+
<RecordChrome
|
|
3824
|
+
elapsed={recording.elapsed}
|
|
3825
|
+
sound={recording.sound}
|
|
3826
|
+
soundNext={recording.soundNext}
|
|
3827
|
+
dictation={
|
|
3828
|
+
(recordInto === "reply" ? replyId : draft)
|
|
3829
|
+
? {
|
|
3830
|
+
listening: recordDictation.listening,
|
|
3831
|
+
queued: recordDictation.state.queued,
|
|
3832
|
+
toggle: () => {
|
|
3833
|
+
dictatingWithRecording.current = !recordDictation.listening;
|
|
3834
|
+
// The screen recording carries the voice already.
|
|
3835
|
+
recordDictation.toggle(null, false);
|
|
3836
|
+
},
|
|
3837
|
+
}
|
|
3838
|
+
: undefined
|
|
3839
|
+
}
|
|
3840
|
+
onToggleSound={recording.toggleSound}
|
|
3841
|
+
onStop={recording.stop}
|
|
3842
|
+
onCancel={recording.cancel}
|
|
3843
|
+
/>
|
|
3844
|
+
) : null}
|
|
3845
|
+
{mode === "select" || mode === "reattach" ? <PickOutline store={highlightStore} /> : null}
|
|
3846
|
+
{mode === "draw" ? <DrawLayer onStroke={onStroke} /> : null}
|
|
3847
|
+
{draft?.anchor.sketch && draftBox ? (
|
|
3848
|
+
<SketchInk sketch={draft.anchor.sketch} box={draftBox} active />
|
|
3849
|
+
) : null}
|
|
3850
|
+
{flash ? (
|
|
3851
|
+
<div
|
|
3852
|
+
className="cs-outline is-flash"
|
|
3853
|
+
style={{ top: flash.top, left: flash.left, width: flash.width, height: flash.height }}
|
|
3854
|
+
/>
|
|
3855
|
+
) : null}
|
|
3856
|
+
{focusBox && mode === "browse" ? (
|
|
3857
|
+
<div
|
|
3858
|
+
className="cs-outline is-selected"
|
|
3859
|
+
data-cs-ui="focus-target"
|
|
3860
|
+
style={{
|
|
3861
|
+
top: focusBox.top,
|
|
3862
|
+
left: focusBox.left,
|
|
3863
|
+
width: focusBox.width,
|
|
3864
|
+
height: focusBox.height,
|
|
3865
|
+
}}
|
|
3866
|
+
/>
|
|
3867
|
+
) : null}
|
|
3868
|
+
{mode !== "browse" ? (
|
|
3869
|
+
<div className="cs-banner cs-hit" data-cs-ui="mode">
|
|
3870
|
+
{mode === "draw" ? <Icon.Scribble /> : <Icon.Cursor />}
|
|
3871
|
+
<span>
|
|
3872
|
+
{mode === "reattach"
|
|
3873
|
+
? "Click the element this comment belongs to"
|
|
3874
|
+
: mode === "draw"
|
|
3875
|
+
? "Circle or draw on anything to comment"
|
|
3876
|
+
: "Click anything to comment"}
|
|
3877
|
+
</span>
|
|
3878
|
+
<button
|
|
3879
|
+
type="button"
|
|
3880
|
+
onClick={() => {
|
|
3881
|
+
setMode("browse");
|
|
3882
|
+
setReattachId(null);
|
|
3883
|
+
}}
|
|
3884
|
+
>
|
|
3885
|
+
Cancel
|
|
3886
|
+
</button>
|
|
3887
|
+
</div>
|
|
3888
|
+
) : null}
|
|
3889
|
+
{pinsHidden
|
|
3890
|
+
? null
|
|
3891
|
+
: scoped.map((item) =>
|
|
3892
|
+
item.thread.anchor.sketch &&
|
|
3893
|
+
item.box &&
|
|
3894
|
+
(item.status === "pinned" || item.status === "drifted") ? (
|
|
3895
|
+
<SketchInk
|
|
3896
|
+
key={`ink-${item.thread.id}`}
|
|
3897
|
+
sketch={item.thread.anchor.sketch}
|
|
3898
|
+
box={item.box}
|
|
3899
|
+
active={focusId === item.thread.id || peekId === item.thread.id}
|
|
3900
|
+
/>
|
|
3901
|
+
) : null,
|
|
3902
|
+
)}
|
|
3903
|
+
{pinsHidden
|
|
3904
|
+
? null
|
|
3905
|
+
: scoped.map((item) =>
|
|
3906
|
+
isUnset(item.top) ||
|
|
3907
|
+
isUnset(item.left) ||
|
|
3908
|
+
(item.status !== "pinned" && item.status !== "drifted") ? null : (
|
|
3909
|
+
<button
|
|
3910
|
+
key={item.thread.id}
|
|
3911
|
+
type="button"
|
|
3912
|
+
className={[
|
|
3913
|
+
"cs-pin",
|
|
3914
|
+
item.status === "drifted" ? "is-drifted" : "",
|
|
3915
|
+
linkedEnv(item.thread) ? "is-linked" : "",
|
|
3916
|
+
focusId === item.thread.id ? "is-active" : "",
|
|
3917
|
+
].join(" ")}
|
|
3918
|
+
data-cs-ui="pin"
|
|
3919
|
+
data-thread-id={item.thread.id}
|
|
3920
|
+
data-cs-status={item.status}
|
|
3921
|
+
data-cs-env={item.thread.environment ?? session.environment}
|
|
3922
|
+
aria-label={`Comment by ${item.thread.createdBy}${linkedEnv(item.thread) ? ` in ${linkedEnv(item.thread)}` : ""}`}
|
|
3923
|
+
aria-expanded={focusId === item.thread.id}
|
|
3924
|
+
style={{
|
|
3925
|
+
top: item.top >= 0 && item.top < 12 ? 12 : item.top,
|
|
3926
|
+
left: pinLeft(item.left),
|
|
3927
|
+
}}
|
|
3928
|
+
onMouseEnter={() => setPeekId(item.thread.id)}
|
|
3929
|
+
onMouseLeave={() => setPeekId(null)}
|
|
3930
|
+
onClick={() => {
|
|
3931
|
+
setPeekId(null);
|
|
3932
|
+
setFocusMenu(false);
|
|
3933
|
+
if (focusId === item.thread.id) {
|
|
3934
|
+
setFocusId(null);
|
|
3935
|
+
return;
|
|
3936
|
+
}
|
|
3937
|
+
setSurface("none");
|
|
3938
|
+
setFocusId(item.thread.id);
|
|
3939
|
+
}}
|
|
3940
|
+
>
|
|
3941
|
+
<Avatar name={item.thread.createdBy} size={20} />
|
|
3942
|
+
{item.thread.messages.length > 1 ? (
|
|
3943
|
+
<span className="cs-pin-count">{item.thread.messages.length}</span>
|
|
3944
|
+
) : null}
|
|
3945
|
+
{linkedEnv(item.thread) ? (
|
|
3946
|
+
<span className="cs-pin-env">{linkedEnv(item.thread)}</span>
|
|
3947
|
+
) : null}
|
|
3948
|
+
</button>
|
|
3949
|
+
),
|
|
3950
|
+
)}
|
|
3951
|
+
{peek &&
|
|
3952
|
+
!pinsHidden &&
|
|
3953
|
+
peek.thread.id !== focusId &&
|
|
3954
|
+
!isUnset(peek.top) &&
|
|
3955
|
+
!isUnset(peek.left) ? (
|
|
3956
|
+
<Peek x={pinLeft(peek.left)} y={peek.top} bounds={bounds}>
|
|
3957
|
+
<div className="cs-meta">
|
|
3958
|
+
<Avatar name={peek.thread.createdBy} size={14} />
|
|
3959
|
+
<strong>{peek.thread.createdBy}</strong>
|
|
3960
|
+
<time>{ago(peek.thread.createdAt)}</time>
|
|
3961
|
+
{envBadge(peek.thread)}
|
|
3962
|
+
</div>
|
|
3963
|
+
<p className="cs-text">
|
|
3964
|
+
<MessageText
|
|
3965
|
+
body={peek.thread.messages[0]?.body ?? ""}
|
|
3966
|
+
mentions={peek.thread.messages[0]?.mentions}
|
|
3967
|
+
/>
|
|
3968
|
+
</p>
|
|
3969
|
+
{peek.thread.messages.length > 1 ? (
|
|
3970
|
+
<div className="cs-replies">
|
|
3971
|
+
{peek.thread.messages.length - 1}{" "}
|
|
3972
|
+
{peek.thread.messages.length === 2 ? "reply" : "replies"}
|
|
3973
|
+
</div>
|
|
3974
|
+
) : null}
|
|
3975
|
+
</Peek>
|
|
3976
|
+
) : null}
|
|
3977
|
+
{focusCard}
|
|
3978
|
+
{(() => {
|
|
3979
|
+
if (!draft) return null;
|
|
3980
|
+
// Beside a page screenshot the composer is a column of the surface, not a card next to an element.
|
|
3981
|
+
const docked = pageDraft;
|
|
3982
|
+
const composer = (
|
|
3983
|
+
<form
|
|
3984
|
+
className={`cs-card cs-composer cs-hit${docked ? " is-docked" : ""}${composerSize && !docked ? " is-sized" : ""}${resizingComposer ? " is-resizing" : ""}`}
|
|
3985
|
+
ref={composerRef}
|
|
3986
|
+
data-cs-ui="composer"
|
|
3987
|
+
data-cs-target={[draft.anchor.component?.file, targetName(draft.anchor)]
|
|
3988
|
+
.filter(Boolean)
|
|
3989
|
+
.join(" · ")}
|
|
3990
|
+
style={
|
|
3991
|
+
docked
|
|
3992
|
+
? undefined
|
|
3993
|
+
: composerSize
|
|
3994
|
+
? { ...composerStyle, width: composerSize.width, minHeight: composerSize.height }
|
|
3995
|
+
: composerStyle
|
|
3996
|
+
}
|
|
3997
|
+
onSubmit={async (event) => {
|
|
3998
|
+
event.preventDefault();
|
|
3999
|
+
// Read first: a submit that stops below must not leave "Send as is" set for the next one.
|
|
4000
|
+
const how = clarity.takeSend();
|
|
4001
|
+
if (
|
|
4002
|
+
!member ||
|
|
4003
|
+
!canPost ||
|
|
4004
|
+
(draftFiles.uploading && !docked) ||
|
|
4005
|
+
marking ||
|
|
4006
|
+
capturing ||
|
|
4007
|
+
recording.phase !== "idle" ||
|
|
4008
|
+
draftDictation.busy ||
|
|
4009
|
+
clarity.dictating
|
|
4010
|
+
)
|
|
4011
|
+
return;
|
|
4012
|
+
const missing = testerForm ? missingTicketFields(testerForm, ticketValues) : [];
|
|
4013
|
+
if (missing.length) {
|
|
4014
|
+
setError(`Fill in ${missing.join(", ")}.`);
|
|
4015
|
+
return;
|
|
4016
|
+
}
|
|
4017
|
+
const ticket = testerForm ? ticketDraftPayload(testerForm, ticketValues) : null;
|
|
4018
|
+
setBusy(true);
|
|
4019
|
+
let attachments = draftFiles.ids;
|
|
4020
|
+
let fileKeys = draftFiles.items.map((item) => item.key);
|
|
4021
|
+
if (docked) {
|
|
4022
|
+
// What was drawn on each picture goes into it now, and the uploads are waited for, not asked for.
|
|
4023
|
+
try {
|
|
4024
|
+
for (const item of draftFiles.items) {
|
|
4025
|
+
const drawn = captureShapes.get(item.key);
|
|
4026
|
+
if (!drawn) continue;
|
|
4027
|
+
const flattened = await flattenCapture(item, drawn);
|
|
4028
|
+
if (!flattened) continue;
|
|
4029
|
+
const next = draftFiles.replace(item.key, flattened);
|
|
4030
|
+
if (!next) {
|
|
4031
|
+
setBusy(false);
|
|
4032
|
+
return;
|
|
4033
|
+
}
|
|
4034
|
+
withShapes((shapes) => {
|
|
4035
|
+
shapes.delete(item.key);
|
|
4036
|
+
shapes.set(next, drawn);
|
|
4037
|
+
});
|
|
4038
|
+
if (shot?.key === item.key) setShot({ key: next });
|
|
4039
|
+
}
|
|
4040
|
+
} catch (caught) {
|
|
4041
|
+
setError(caught instanceof Error ? caught.message : "Couldn’t save a picture.");
|
|
4042
|
+
setBusy(false);
|
|
4043
|
+
return;
|
|
4044
|
+
}
|
|
4045
|
+
const files = await draftFiles.settled();
|
|
4046
|
+
const failed = files.find((item) => item.status === "failed");
|
|
4047
|
+
if (failed) {
|
|
4048
|
+
setError(
|
|
4049
|
+
failed.error
|
|
4050
|
+
? `${failed.name}: ${failed.error}`
|
|
4051
|
+
: `${failed.name} didn’t upload.`,
|
|
4052
|
+
);
|
|
4053
|
+
setBusy(false);
|
|
4054
|
+
return;
|
|
4055
|
+
}
|
|
4056
|
+
attachments = files
|
|
4057
|
+
.filter((item) => item.status === "ready" && item.id)
|
|
4058
|
+
.map((item) => item.id!);
|
|
4059
|
+
fileKeys = files.map((item) => item.key);
|
|
4060
|
+
}
|
|
4061
|
+
// Where the environment checks new comments, a reader's questions come before the post. This
|
|
4062
|
+
// always answers, and where it has nothing to ask (or could not ask) the text is `body`, untouched.
|
|
4063
|
+
const sent = await clarity.send(
|
|
4064
|
+
{
|
|
4065
|
+
body,
|
|
4066
|
+
url: new URL(draft.href ?? pageHref(), window.location.origin).href,
|
|
4067
|
+
route: draft.anchor.route,
|
|
4068
|
+
target: docked ? "the whole page" : targetName(draft.anchor),
|
|
4069
|
+
attachmentIds: attachments,
|
|
4070
|
+
fileKeys,
|
|
4071
|
+
posted: {
|
|
4072
|
+
...detailsPayload(settings, draftDetails),
|
|
4073
|
+
...(ticket ? { ticket } : {}),
|
|
4074
|
+
},
|
|
4075
|
+
},
|
|
4076
|
+
how,
|
|
4077
|
+
);
|
|
4078
|
+
if (!sent.post) {
|
|
4079
|
+
setBusy(false);
|
|
4080
|
+
return;
|
|
4081
|
+
}
|
|
4082
|
+
const picked = mentionsIn(body, draftPicked.current);
|
|
4083
|
+
const screenshot = picked.length
|
|
4084
|
+
? await (draftShot.current ?? cropDraft(draft.element, draft.anchor.sketch)).catch(
|
|
4085
|
+
() => null,
|
|
4086
|
+
)
|
|
4087
|
+
: null;
|
|
4088
|
+
// After the clarity check, so the context never reaches its model. A message holds five files;
|
|
4089
|
+
// the person's own come first.
|
|
4090
|
+
const contextId =
|
|
4091
|
+
attachments.length < MAX_FILES_PER_MESSAGE
|
|
4092
|
+
? await uploadDebugContext(
|
|
4093
|
+
debugCapture.current,
|
|
4094
|
+
{
|
|
4095
|
+
element: docked ? null : draft.element,
|
|
4096
|
+
anchor: draft.anchor,
|
|
4097
|
+
steps: replayBuffer.current.events(),
|
|
4098
|
+
},
|
|
4099
|
+
(blob, name) => uploadAttachment(session, member, blob, name),
|
|
4100
|
+
)
|
|
4101
|
+
: null;
|
|
4102
|
+
createThread(session, member, draft.anchor, sent.body, {
|
|
4103
|
+
mentions: picked.map((person) => person.id),
|
|
4104
|
+
screenshot,
|
|
4105
|
+
attachments: contextId ? [...attachments, contextId] : attachments,
|
|
4106
|
+
url: draft.href ?? pageHref(),
|
|
4107
|
+
...detailsPayload(settings, draftDetails),
|
|
4108
|
+
...(ticket ? { ticket } : {}),
|
|
4109
|
+
...(draftKind ? { kind: draftKind } : {}),
|
|
4110
|
+
})
|
|
4111
|
+
.then(async ({ thread, delivery }) => {
|
|
4112
|
+
setDraft(null);
|
|
4113
|
+
setBody("");
|
|
4114
|
+
setDraftDetails(noDetails);
|
|
4115
|
+
setDraftKind(null);
|
|
4116
|
+
setTicketValues({});
|
|
4117
|
+
draftFiles.reset();
|
|
4118
|
+
setError("");
|
|
4119
|
+
draftPicked.current = new Map();
|
|
4120
|
+
draftShot.current = null;
|
|
4121
|
+
draftPage.current = null;
|
|
4122
|
+
void draftStore?.clear();
|
|
4123
|
+
setStored(null);
|
|
4124
|
+
resetPageDraft();
|
|
4125
|
+
const firstId = thread.messages[0]?.id;
|
|
4126
|
+
const note = delivery ? deliveryText(delivery) : "";
|
|
4127
|
+
if (delivery && firstId)
|
|
4128
|
+
setDeliveries((current) => ({ ...current, [firstId]: delivery }));
|
|
4129
|
+
// Posting opens nothing: the composer closes, the pin appears and the person carries on.
|
|
4130
|
+
say(note || "Comment posted");
|
|
4131
|
+
await refresh();
|
|
4132
|
+
})
|
|
4133
|
+
.catch((caught) =>
|
|
4134
|
+
setError(caught instanceof Error ? caught.message : "Couldn’t post."),
|
|
4135
|
+
)
|
|
4136
|
+
.finally(() => setBusy(false));
|
|
4137
|
+
}}
|
|
4138
|
+
>
|
|
4139
|
+
{docked ? null : (
|
|
4140
|
+
<ResizeGrip
|
|
4141
|
+
room={() => overlayBounds(portal ?? document.body)}
|
|
4142
|
+
onResize={setResizingComposer}
|
|
4143
|
+
onEnd={(size) => {
|
|
4144
|
+
setResizingComposer(null);
|
|
4145
|
+
savePrefs({ ...prefsRef.current, composerSize: size });
|
|
4146
|
+
}}
|
|
4147
|
+
onReset={() => {
|
|
4148
|
+
setResizingComposer(null);
|
|
4149
|
+
const { composerSize: _reset, ...rest } = prefsRef.current;
|
|
4150
|
+
savePrefs(rest);
|
|
4151
|
+
}}
|
|
4152
|
+
/>
|
|
4153
|
+
)}
|
|
4154
|
+
<button
|
|
4155
|
+
className="cs-icon-button cs-composer-close"
|
|
4156
|
+
type="button"
|
|
4157
|
+
aria-label={docked ? "Minimise" : "Discard"}
|
|
4158
|
+
onClick={() => (docked ? setCollecting(true) : setDraft(null))}
|
|
4159
|
+
>
|
|
4160
|
+
<Icon.Close />
|
|
4161
|
+
</button>
|
|
4162
|
+
<div
|
|
4163
|
+
className="cs-composer-body"
|
|
4164
|
+
onClick={(event) => {
|
|
4165
|
+
// A resized box has room below the text: a click there writes, like in the text itself.
|
|
4166
|
+
// (Docked, the body hugs the text, so this never fires on the column's space below.)
|
|
4167
|
+
// (A click, because the toolbar's shield stops mousedown before React sees it.)
|
|
4168
|
+
if (event.target !== event.currentTarget) return;
|
|
4169
|
+
const field = composerField();
|
|
4170
|
+
if (!field) return;
|
|
4171
|
+
field.focus();
|
|
4172
|
+
field.setSelectionRange(field.value.length, field.value.length);
|
|
4173
|
+
}}
|
|
4174
|
+
>
|
|
4175
|
+
<MentionTextarea
|
|
4176
|
+
data-cs-ui="composer-body"
|
|
4177
|
+
aria-label="Comment"
|
|
4178
|
+
placeholder="Add a comment… (@ to mention)"
|
|
4179
|
+
value={body}
|
|
4180
|
+
autoFocus
|
|
4181
|
+
grow
|
|
4182
|
+
onValueChange={setBody}
|
|
4183
|
+
members={ownMembers}
|
|
4184
|
+
selfEmail={readJwt(member ?? "")?.email}
|
|
4185
|
+
onMention={(person) => {
|
|
4186
|
+
draftPicked.current.set(person.id, person);
|
|
4187
|
+
if (!draftShot.current)
|
|
4188
|
+
draftShot.current = cropDraft(draft.element, draft.anchor.sketch);
|
|
4189
|
+
}}
|
|
4190
|
+
onKeyDown={(event) => {
|
|
4191
|
+
if (isDictationKey(event)) {
|
|
4192
|
+
event.preventDefault();
|
|
4193
|
+
draftDictation.toggle(event.currentTarget);
|
|
4194
|
+
return;
|
|
4195
|
+
}
|
|
4196
|
+
submitOnCmdEnter(event);
|
|
4197
|
+
}}
|
|
4198
|
+
onPaste={(event) => {
|
|
4199
|
+
const files = filesFromClipboard(event);
|
|
4200
|
+
if (files.length) {
|
|
4201
|
+
event.preventDefault();
|
|
4202
|
+
draftFiles.add(files);
|
|
4203
|
+
}
|
|
4204
|
+
}}
|
|
4205
|
+
/>
|
|
4206
|
+
</div>
|
|
4207
|
+
<DetailsInput
|
|
4208
|
+
settings={settings}
|
|
4209
|
+
value={draftDetails}
|
|
4210
|
+
onChange={setDraftDetails}
|
|
4211
|
+
onKeyDown={submitOnCmdEnter}
|
|
4212
|
+
/>
|
|
4213
|
+
<KindInput value={draftKind} onChange={setDraftKind} />
|
|
4214
|
+
{memberType === "tester" ? (
|
|
4215
|
+
testerForm ? (
|
|
4216
|
+
<TicketFormInput
|
|
4217
|
+
form={testerForm}
|
|
4218
|
+
values={ticketValues}
|
|
4219
|
+
onChange={setTicketValues}
|
|
4220
|
+
onKeyDown={submitOnCmdEnter}
|
|
4221
|
+
/>
|
|
4222
|
+
) : (
|
|
4223
|
+
<p className="cs-hint cs-pad" data-cs-ui="ticket-form-status">
|
|
4224
|
+
{ticketFormError || "Loading the ticket form…"}
|
|
4225
|
+
</p>
|
|
4226
|
+
)
|
|
4227
|
+
) : null}
|
|
4228
|
+
<AttachmentTray
|
|
4229
|
+
// The page screenshot is the surface itself, not a tray item, while the composer is docked beside it.
|
|
4230
|
+
items={
|
|
4231
|
+
docked
|
|
4232
|
+
? draftFiles.items.filter(
|
|
4233
|
+
(item) => !captures.some((file) => file.id === item.key),
|
|
4234
|
+
)
|
|
4235
|
+
: draftFiles.items
|
|
4236
|
+
}
|
|
4237
|
+
activeKey={docked ? null : trayKey}
|
|
4238
|
+
onPreview={
|
|
4239
|
+
docked
|
|
4240
|
+
? undefined
|
|
4241
|
+
: (item) => {
|
|
4242
|
+
setPreview(null);
|
|
4243
|
+
setTrayKey((current) => (current === item.key ? null : item.key));
|
|
4244
|
+
}
|
|
4245
|
+
}
|
|
4246
|
+
onRemove={draftFiles.remove}
|
|
4247
|
+
/>
|
|
4248
|
+
<ClarityQuestions
|
|
4249
|
+
clarity={clarity}
|
|
4250
|
+
mode={clarityMode}
|
|
4251
|
+
body={body}
|
|
4252
|
+
busy={busy}
|
|
4253
|
+
voice={{
|
|
4254
|
+
transcribe,
|
|
4255
|
+
names: memberNames,
|
|
4256
|
+
onNote: setError,
|
|
4257
|
+
off: busy || recording.phase !== "idle",
|
|
4258
|
+
}}
|
|
4259
|
+
onKeyDown={submitOnCmdEnter}
|
|
4260
|
+
/>
|
|
4261
|
+
{error ? <p className="cs-error cs-pad">{error}</p> : null}
|
|
4262
|
+
<div className="cs-composer-foot">
|
|
4263
|
+
{draftDictation.shown ? null : (
|
|
4264
|
+
<AttachButtons
|
|
4265
|
+
onFiles={draftFiles.add}
|
|
4266
|
+
capturing={capturing || recording.phase !== "idle"}
|
|
4267
|
+
onSnapshot={() =>
|
|
4268
|
+
snapshot(takeShot(draft.element), draftFiles.add, draft.anchor.sketch)
|
|
4269
|
+
}
|
|
4270
|
+
onRecord={() => {
|
|
4271
|
+
setRecordInto("draft");
|
|
4272
|
+
void recording.begin(draftFiles.add);
|
|
4273
|
+
}}
|
|
4274
|
+
onReplay={() => attachReplay(draftFiles.add)}
|
|
4275
|
+
/>
|
|
4276
|
+
)}
|
|
4277
|
+
<DictateButton
|
|
4278
|
+
dictation={draftDictation}
|
|
4279
|
+
field="composer-body"
|
|
4280
|
+
disabled={busy || recording.phase !== "idle"}
|
|
4281
|
+
/>
|
|
4282
|
+
{capturing || (draftFiles.uploading && !docked) || busy || marking ? (
|
|
4283
|
+
<span className="cs-hint">
|
|
4284
|
+
{capturing
|
|
4285
|
+
? "Capturing…"
|
|
4286
|
+
: draftFiles.uploading && !docked
|
|
4287
|
+
? "Uploading…"
|
|
4288
|
+
: clarity.checking
|
|
4289
|
+
? CLARITY_WORDS.working
|
|
4290
|
+
: busy
|
|
4291
|
+
? "Posting…"
|
|
4292
|
+
: "Marking up…"}
|
|
4293
|
+
</span>
|
|
4294
|
+
) : null}
|
|
4295
|
+
<button
|
|
4296
|
+
className="cs-send"
|
|
4297
|
+
data-cs-ui="composer-submit"
|
|
4298
|
+
type="submit"
|
|
4299
|
+
aria-label="Post comment"
|
|
4300
|
+
aria-busy={clarity.checking || undefined}
|
|
4301
|
+
disabled={
|
|
4302
|
+
busy ||
|
|
4303
|
+
capturing ||
|
|
4304
|
+
recording.phase !== "idle" ||
|
|
4305
|
+
(draftFiles.uploading && !docked) ||
|
|
4306
|
+
marking ||
|
|
4307
|
+
draftDictation.busy ||
|
|
4308
|
+
clarity.dictating ||
|
|
4309
|
+
!canPost
|
|
4310
|
+
}
|
|
4311
|
+
>
|
|
4312
|
+
<Icon.Send />
|
|
4313
|
+
</button>
|
|
4314
|
+
</div>
|
|
4315
|
+
{trayPane && !docked ? (
|
|
4316
|
+
<MediaPop bounds={bounds} left={composerStyle.left} top={composerStyle.top}>
|
|
4317
|
+
{trayPane}
|
|
4318
|
+
</MediaPop>
|
|
4319
|
+
) : null}
|
|
4320
|
+
</form>
|
|
4321
|
+
);
|
|
4322
|
+
if (!docked) return composer;
|
|
4323
|
+
if (collecting) return null;
|
|
4324
|
+
return (
|
|
4325
|
+
<ShotSurface
|
|
4326
|
+
captures={captures}
|
|
4327
|
+
selectedId={shot?.key ?? null}
|
|
4328
|
+
shapes={captureShapes}
|
|
4329
|
+
savedAt={savedAt}
|
|
4330
|
+
onSelect={(id) => setShot({ key: id })}
|
|
4331
|
+
onRemove={(id) => {
|
|
4332
|
+
draftFiles.remove(id);
|
|
4333
|
+
withShapes((shapes) => shapes.delete(id));
|
|
4334
|
+
}}
|
|
4335
|
+
onShapes={(id, next) => withShapes((shapes) => shapes.set(id, next))}
|
|
4336
|
+
onAnother={() => setCollecting(true)}
|
|
4337
|
+
onRecord={recordIntoDraft}
|
|
4338
|
+
onDictate={() => {
|
|
4339
|
+
const field = composerField();
|
|
4340
|
+
if (field) draftDictation.toggle(field);
|
|
4341
|
+
}}
|
|
4342
|
+
dictating={draftDictation.listening}
|
|
4343
|
+
onMinimise={() => setCollecting(true)}
|
|
4344
|
+
onDiscard={discardDraft}
|
|
4345
|
+
confirmDiscard={confirmDiscard}
|
|
4346
|
+
onSubmit={() => composerForm()?.requestSubmit()}
|
|
4347
|
+
>
|
|
4348
|
+
{composer}
|
|
4349
|
+
</ShotSurface>
|
|
4350
|
+
);
|
|
4351
|
+
})()}
|
|
4352
|
+
{draftDock
|
|
4353
|
+
? (() => {
|
|
4354
|
+
const live = draftDock === "collecting";
|
|
4355
|
+
const about = live
|
|
4356
|
+
? describeDraft({
|
|
4357
|
+
body,
|
|
4358
|
+
captures: draftFiles.items.map((item) => ({ type: item.contentType })),
|
|
4359
|
+
})
|
|
4360
|
+
: describeDraft(stored!);
|
|
4361
|
+
return (
|
|
4362
|
+
<DraftDock
|
|
4363
|
+
title={about.title}
|
|
4364
|
+
pictures={about.pictures}
|
|
4365
|
+
clips={about.clips}
|
|
4366
|
+
savedAt={live ? savedAt : stored!.updatedAt}
|
|
4367
|
+
mode={draftDock}
|
|
4368
|
+
note={live ? undefined : stored!.note}
|
|
4369
|
+
recording={recording.phase !== "idle"}
|
|
4370
|
+
confirmDiscard={confirmDiscard}
|
|
4371
|
+
onCapture={() => void addCapture()}
|
|
4372
|
+
onRecord={recordIntoDraft}
|
|
4373
|
+
onOpen={() => (live ? setCollecting(false) : resumeDraft(stored!))}
|
|
4374
|
+
onDiscard={discardDraft}
|
|
4375
|
+
/>
|
|
4376
|
+
);
|
|
4377
|
+
})()
|
|
4378
|
+
: null}
|
|
4379
|
+
{surface === "unlock" && disabled
|
|
4380
|
+
? disabledCard
|
|
4381
|
+
: surface === "unlock" && !member
|
|
4382
|
+
? unlockCard
|
|
4383
|
+
: null}
|
|
4384
|
+
{surface === "dashboard" && member ? (
|
|
4385
|
+
<Dashboard
|
|
4386
|
+
rows={scoped}
|
|
4387
|
+
here={here}
|
|
4388
|
+
environment={session.environment}
|
|
4389
|
+
memberName={memberName}
|
|
4390
|
+
memberId={memberId}
|
|
4391
|
+
roles={settings?.roles.enabled ? settings.roles.options : []}
|
|
4392
|
+
members={ownMembers}
|
|
4393
|
+
session={session}
|
|
4394
|
+
memberToken={member}
|
|
4395
|
+
envFilter={envChips}
|
|
4396
|
+
profile={profilePane}
|
|
4397
|
+
settingsPane={settingsPane}
|
|
4398
|
+
onClose={() => setSurface("none")}
|
|
4399
|
+
onRefresh={refresh}
|
|
4400
|
+
readOnly={readOnly}
|
|
4401
|
+
screen={dashboardScreen}
|
|
4402
|
+
onScreen={setDashboardScreen}
|
|
4403
|
+
focus={dashboardFocus}
|
|
4404
|
+
onOpen={(row) => {
|
|
4405
|
+
const item = placed.find((candidate) => candidate.thread.id === row.thread.id);
|
|
4406
|
+
if (item) showThread(item);
|
|
4407
|
+
}}
|
|
4408
|
+
onReattach={(row) => {
|
|
4409
|
+
setReattachId(row.thread.id);
|
|
4410
|
+
setMode("reattach");
|
|
4411
|
+
setFocusId(null);
|
|
4412
|
+
setFocusMenu(false);
|
|
4413
|
+
setSurface("none");
|
|
4414
|
+
}}
|
|
4415
|
+
/>
|
|
4416
|
+
) : null}
|
|
4417
|
+
{toast ? (
|
|
4418
|
+
<div key={toast} className="cs-toast" role="status">
|
|
4419
|
+
{toast}
|
|
4420
|
+
</div>
|
|
4421
|
+
) : null}
|
|
4422
|
+
{draftDock ? null : bar}
|
|
4423
|
+
</div>
|
|
4424
|
+
);
|
|
4425
|
+
|
|
4426
|
+
return createPortal(ui, host);
|
|
4427
|
+
}
|