| 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { |
| 3 | isModifierPairShortcut, |
| 4 | type ClientSettingsPatch, |
| 5 | type DesktopSnapShotShortcutAvailability, |
| 6 | type DesktopSnapShotState, |
| 7 | type DesktopSnapShotSetupAction, |
| 8 | type SnapShotShortcut, |
| 9 | } from "@t3tools/contracts"; |
| 10 | import { PlayIcon } from "lucide-react"; |
| 11 | import { useCallback, useEffect, useRef, useState } from "react"; |
| 12 | |
| 13 | import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; |
| 14 | import { getDesktopSnapShotBridge } from "../../lib/desktopSnapShot"; |
| 15 | import { |
| 16 | readSnapShotSetupResume, |
| 17 | saveSnapShotSetupResume, |
| 18 | clearSnapShotSetupResume, |
| 19 | } from "../../lib/snapShotSetupResume"; |
| 20 | import { sameSnapShotShortcut, snapShotKeybindingConflict } from "../../lib/snapShotShortcut"; |
| 21 | import { playSnapShotSound } from "../../lib/snapShotSound"; |
| 22 | import { primaryServerKeybindingsAtom } from "../../state/server"; |
| 23 | import { commandLabel } from "./KeybindingsSettings.logic"; |
| 24 | import { |
| 25 | snapShotStatus, |
| 26 | snapShotShortcutStatus, |
| 27 | snapShotSetupButtonLabel, |
| 28 | snapShotUnavailableMessage, |
| 29 | snapShotSoundPatch, |
| 30 | snapShotFeedbackUnavailableMessage, |
| 31 | snapShotDescription, |
| 32 | snapShotAccessibilityUnavailableMessage, |
| 33 | snapShotSetupComplete, |
| 34 | type SnapShotSoundSelection, |
| 35 | } from "./SnapShotSettings.logic"; |
| 36 | import { |
| 37 | SettingsUnavailableGroup, |
| 38 | SettingsPageContainer, |
| 39 | SettingsRow, |
| 40 | SettingsSection, |
| 41 | } from "./settingsLayout"; |
| 42 | import { searchableSetting } from "./settingsSearch"; |
| 43 | import { Button } from "../ui/button"; |
| 44 | import { Menu, MenuItem, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../ui/menu"; |
| 45 | import { SelectButton } from "../ui/select"; |
| 46 | import { Switch } from "../ui/switch"; |
| 47 | import { toastManager } from "../ui/toast"; |
| 48 | import { SnapShotSetupDialog } from "./SnapShotSetupDialog"; |
| 49 | import { useSnapShotShortcutRecorder } from "./useSnapShotShortcutRecorder"; |
| 50 | import { |
| 51 | captureSetupAccessReady, |
| 52 | captureSetupInitialStep, |
| 53 | captureSetupShouldDisableOnClose, |
| 54 | type CaptureSetupStep, |
| 55 | } from "./SnapShotSetupDialog.logic"; |
| 56 | |
| 57 | const soundOptionRowClassName = "grid grid-cols-[1fr_auto]"; |
| 58 | |
| 59 | function captureSettingsError(title: string, error: unknown) { |
| 60 | return { title, message: error instanceof Error ? error.message : "Try again." }; |
| 61 | } |
| 62 | |
| 63 | type ShortcutCheck = |
| 64 | | { readonly status: "idle"; readonly availability: null } |
| 65 | | { readonly status: "checking"; readonly availability: null } |
| 66 | | { |
| 67 | readonly status: "checked"; |
| 68 | readonly availability: DesktopSnapShotShortcutAvailability; |
| 69 | }; |
| 70 | |
| 71 | export function SnapShotSettings() { |
| 72 | const settings = useClientSettings(); |
| 73 | const updateSettings = useUpdateClientSettings(); |
| 74 | const keybindings = useAtomValue(primaryServerKeybindingsAtom); |
| 75 | const bridge = getDesktopSnapShotBridge(); |
| 76 | const [state, setState] = useState<DesktopSnapShotState | null>(null); |
| 77 | const [setupBusy, setSetupBusy] = useState(false); |
| 78 | const [setupError, setSetupError] = useState<ReturnType<typeof captureSettingsError> | null>( |
| 79 | null, |
| 80 | ); |
| 81 | const [wizard, setWizard] = useState<{ |
| 82 | initialStep: CaptureSetupStep; |
| 83 | wasEnabled: boolean; |
| 84 | } | null>(null); |
| 85 | const [candidate, setCandidate] = useState<SnapShotShortcut>(settings.snapShotShortcut); |
| 86 | const [shortcutCheck, setShortcutCheck] = useState<ShortcutCheck>({ |
| 87 | status: "idle", |
| 88 | availability: null, |
| 89 | }); |
| 90 | const shortcutCheckIdRef = useRef(0); |
| 91 | const stateRequestIdRef = useRef(0); |
| 92 | const unavailableMessage = snapShotUnavailableMessage(Boolean(bridge)); |
| 93 | const captureAvailable = Boolean(bridge) && state !== null && state.mode !== "unavailable"; |
| 94 | const feedbackUnavailable = snapShotFeedbackUnavailableMessage(state); |
| 95 | const savedShortcut = settings.snapShotShortcut; |
| 96 | const managedShortcut = state?.linuxBackend === "niri" || state?.linuxBackend === "hyprland"; |
| 97 | const shortcutChanged = !managedShortcut && !sameSnapShotShortcut(candidate, savedShortcut); |
| 98 | const displayShortcut = shortcutChanged ? candidate : (state?.shortcut ?? savedShortcut); |
| 99 | const candidateConflict = shortcutChanged |
| 100 | ? snapShotKeybindingConflict(candidate, keybindings) |
| 101 | : null; |
| 102 | const canSaveShortcut = |
| 103 | shortcutChanged && candidateConflict === null && shortcutCheck.availability?.available === true; |
| 104 | const soundSelection = settings.snapShotPlaySound ? settings.snapShotSound : "off"; |
| 105 | const soundLabel = |
| 106 | soundSelection === "off" ? "Off" : soundSelection === "soft-pop" ? "Whoosh (Default)" : "Click"; |
| 107 | |
| 108 | const refreshState = useCallback(async () => { |
| 109 | const requestId = ++stateRequestIdRef.current; |
| 110 | try { |
| 111 | if (bridge) { |
| 112 | const nextState = await bridge.getSnapShotState(); |
| 113 | if (requestId === stateRequestIdRef.current) setState(nextState); |
| 114 | return nextState; |
| 115 | } |
| 116 | } catch (error) { |
| 117 | if (requestId === stateRequestIdRef.current) |
| 118 | setSetupError(captureSettingsError("Couldn't check capture setup", error)); |
| 119 | } |
| 120 | }, [bridge]); |
| 121 | |
| 122 | const setup = useCallback( |
| 123 | async (action: DesktopSnapShotSetupAction) => { |
| 124 | if (!bridge?.setupSnapShot || setupBusy) return; |
| 125 | setSetupBusy(true); |
| 126 | setSetupError(null); |
| 127 | try { |
| 128 | if ( |
| 129 | state?.macPermissions && |
| 130 | wizard && |
| 131 | (action === "allow-screen-recording" || action === "allow-accessibility") |
| 132 | ) { |
| 133 | // macOS can quit the app from its permission prompt. |
| 134 | saveSnapShotSetupResume(wizard.wasEnabled); |
| 135 | } |
| 136 | await bridge.setupSnapShot(action); |
| 137 | await refreshState(); |
| 138 | } catch (error) { |
| 139 | setSetupError( |
| 140 | captureSettingsError( |
| 141 | action === "retry-shortcut" |
| 142 | ? "Couldn't open shortcut permissions" |
| 143 | : "Couldn't complete capture setup", |
| 144 | error, |
| 145 | ), |
| 146 | ); |
| 147 | } finally { |
| 148 | setSetupBusy(false); |
| 149 | } |
| 150 | }, |
| 151 | [bridge, refreshState, setupBusy, state, wizard], |
| 152 | ); |
| 153 | |
| 154 | useEffect(() => { |
| 155 | if (!setupError || wizard) return; |
| 156 | toastManager.add({ |
| 157 | type: "error", |
| 158 | title: setupError.title, |
| 159 | description: setupError.message, |
| 160 | }); |
| 161 | setSetupError(null); |
| 162 | }, [setupError, wizard]); |
| 163 | |
| 164 | useEffect(() => { |
| 165 | let cancelled = false; |
| 166 | void refreshState().then((current) => { |
| 167 | const resume = readSnapShotSetupResume(); |
| 168 | if (!cancelled && current?.macPermissions && resume) { |
| 169 | setWizard({ initialStep: "access", wasEnabled: resume.wasEnabled }); |
| 170 | } |
| 171 | }); |
| 172 | window.addEventListener("focus", refreshState); |
| 173 | return () => { |
| 174 | cancelled = true; |
| 175 | window.removeEventListener("focus", refreshState); |
| 176 | }; |
| 177 | }, [refreshState]); |
| 178 | |
| 179 | useEffect( |
| 180 | () => |
| 181 | bridge?.onSnapShotEvent((event) => { |
| 182 | if (event.type === "shortcut-changed") void refreshState(); |
| 183 | }), |
| 184 | [bridge, refreshState], |
| 185 | ); |
| 186 | |
| 187 | useEffect(() => { |
| 188 | shortcutCheckIdRef.current++; |
| 189 | setCandidate(savedShortcut); |
| 190 | setShortcutCheck({ status: "idle", availability: null }); |
| 191 | }, [savedShortcut]); |
| 192 | |
| 193 | const save = useCallback( |
| 194 | async (patch: ClientSettingsPatch) => { |
| 195 | setSetupError(null); |
| 196 | try { |
| 197 | await updateSettings(patch); |
| 198 | return await refreshState(); |
| 199 | } catch (error) { |
| 200 | setSetupError(captureSettingsError("Couldn't save capture settings", error)); |
| 201 | } |
| 202 | }, |
| 203 | [refreshState, updateSettings], |
| 204 | ); |
| 205 | |
| 206 | const saveIncludeAccessibility = useCallback( |
| 207 | async (includeAccessibility: boolean) => { |
| 208 | try { |
| 209 | if (includeAccessibility && settings.snapShotEnabled) |
| 210 | await bridge?.requestSnapShotPermissions(true); |
| 211 | await save({ snapShotIncludeAccessibility: includeAccessibility }); |
| 212 | } catch (error) { |
| 213 | setSetupError(captureSettingsError("Couldn't allow app text capture", error)); |
| 214 | } |
| 215 | }, |
| 216 | [bridge, save, settings.snapShotEnabled], |
| 217 | ); |
| 218 | |
| 219 | const checkShortcut = useCallback( |
| 220 | async (shortcut: SnapShotShortcut) => { |
| 221 | const checkId = ++shortcutCheckIdRef.current; |
| 222 | setCandidate(shortcut); |
| 223 | const conflict = snapShotKeybindingConflict(shortcut, keybindings); |
| 224 | if (conflict || !bridge) return; |
| 225 | setShortcutCheck({ status: "checking", availability: null }); |
| 226 | try { |
| 227 | const availability = await bridge.checkSnapShotShortcut(shortcut); |
| 228 | if (checkId === shortcutCheckIdRef.current) { |
| 229 | setShortcutCheck({ status: "checked", availability }); |
| 230 | } |
| 231 | } catch (error) { |
| 232 | if (checkId !== shortcutCheckIdRef.current) return; |
| 233 | setShortcutCheck({ |
| 234 | status: "checked", |
| 235 | availability: { |
| 236 | available: false, |
| 237 | message: error instanceof Error ? error.message : "Could not check this shortcut.", |
| 238 | }, |
| 239 | }); |
| 240 | } |
| 241 | }, |
| 242 | [bridge, keybindings], |
| 243 | ); |
| 244 | |
| 245 | const { |
| 246 | recording, |
| 247 | stopRecording, |
| 248 | input: shortcutInput, |
| 249 | } = useSnapShotShortcutRecorder({ |
| 250 | shortcut: displayShortcut, |
| 251 | shortcutLabel: !shortcutChanged ? state?.shortcutLabel : undefined, |
| 252 | disabled: setupBusy, |
| 253 | allowModifierPairs: state?.mode !== "portal", |
| 254 | onRecord: (shortcut) => void checkShortcut(shortcut), |
| 255 | onStart: () => { |
| 256 | shortcutCheckIdRef.current++; |
| 257 | setShortcutCheck({ status: "idle", availability: null }); |
| 258 | }, |
| 259 | onError: (message) => |
| 260 | setShortcutCheck({ status: "checked", availability: { available: false, message } }), |
| 261 | }); |
| 262 | |
| 263 | const shortcutStatus = recording |
| 264 | ? "Press your shortcut. Esc cancels." |
| 265 | : candidateConflict |
| 266 | ? `The Knife already uses this for "${commandLabel(candidateConflict)}".` |
| 267 | : shortcutCheck.status === "checking" |
| 268 | ? "Checking shortcut…" |
| 269 | : shortcutCheck.availability |
| 270 | ? shortcutCheck.availability.available |
| 271 | ? "Ready to save." |
| 272 | : shortcutCheck.availability.message |
| 273 | : state?.mode === "portal" && |
| 274 | !state.shortcutLabel && |
| 275 | isModifierPairShortcut(displayShortcut) |
| 276 | ? "Try a shortcut such as Ctrl+Shift+2." |
| 277 | : snapShotShortcutStatus(state); |
| 278 | |
| 279 | const openSetup = async (requested: CaptureSetupStep | "resume" = "resume") => { |
| 280 | if (!state || setupBusy) return; |
| 281 | stopRecording(); |
| 282 | shortcutCheckIdRef.current++; |
| 283 | setCandidate(savedShortcut); |
| 284 | setShortcutCheck({ status: "idle", availability: null }); |
| 285 | setSetupError(null); |
| 286 | setSetupBusy(true); |
| 287 | try { |
| 288 | let current = await refreshState(); |
| 289 | if (!current) return; |
| 290 | if (current.macPermissions) requested = "access"; |
| 291 | if (!settings.snapShotEnabled && captureSetupInitialStep(current, requested) !== "access") { |
| 292 | // Opening setup is the opt-in. Restore registration before resuming a |
| 293 | // later step, just as Continue does on the access step. |
| 294 | current = await save({ snapShotEnabled: true }); |
| 295 | if (!current) { |
| 296 | // A settings write can succeed even if the following status check |
| 297 | // fails. Keep Finish later available to turn capture back off. |
| 298 | setWizard({ initialStep: "access", wasEnabled: false }); |
| 299 | return; |
| 300 | } |
| 301 | } |
| 302 | setWizard({ |
| 303 | initialStep: captureSetupInitialStep(current, requested), |
| 304 | wasEnabled: settings.snapShotEnabled, |
| 305 | }); |
| 306 | } finally { |
| 307 | setSetupBusy(false); |
| 308 | } |
| 309 | }; |
| 310 | |
| 311 | const enableForSetup = async () => { |
| 312 | if (setupBusy) return false; |
| 313 | setSetupBusy(true); |
| 314 | setSetupError(null); |
| 315 | try { |
| 316 | if (state?.macPermissions) { |
| 317 | saveSnapShotSetupResume(wizard?.wasEnabled ?? settings.snapShotEnabled); |
| 318 | if (!bridge?.setupSnapShot) throw new Error("Restart The Knife to finish capture setup."); |
| 319 | await bridge.setupSnapShot("test-mac-capture"); |
| 320 | } |
| 321 | if (state?.mode === "direct") |
| 322 | await bridge?.requestSnapShotPermissions(settings.snapShotIncludeAccessibility); |
| 323 | const nextState = |
| 324 | settings.snapShotEnabled && !state?.message |
| 325 | ? await refreshState() |
| 326 | : await save({ snapShotEnabled: true }); |
| 327 | return nextState !== undefined && captureSetupAccessReady(nextState); |
| 328 | } catch (error) { |
| 329 | setSetupError(captureSettingsError("Couldn't verify capture access", error)); |
| 330 | return false; |
| 331 | } finally { |
| 332 | setSetupBusy(false); |
| 333 | } |
| 334 | }; |
| 335 | |
| 336 | const closeSetup = async (completed: boolean) => { |
| 337 | if (!wizard || setupBusy) return; |
| 338 | setSetupBusy(true); |
| 339 | try { |
| 340 | if ( |
| 341 | settings.snapShotEnabled && |
| 342 | captureSetupShouldDisableOnClose(wizard.wasEnabled, completed) |
| 343 | ) { |
| 344 | if (!(await save({ snapShotEnabled: false }))) return; |
| 345 | } |
| 346 | stopRecording(); |
| 347 | setSetupError(null); |
| 348 | clearSnapShotSetupResume(); |
| 349 | setWizard(null); |
| 350 | } catch (error) { |
| 351 | setSetupError(captureSettingsError("Couldn't close capture setup", error)); |
| 352 | } finally { |
| 353 | setSetupBusy(false); |
| 354 | } |
| 355 | }; |
| 356 | |
| 357 | const saveShortcut = async () => { |
| 358 | if (!canSaveShortcut || setupBusy) return false; |
| 359 | setSetupBusy(true); |
| 360 | try { |
| 361 | const saved = await save({ snapShotShortcut: candidate }); |
| 362 | return Boolean(saved?.shortcutRegistered || saved?.shortcutPending); |
| 363 | } finally { |
| 364 | setSetupBusy(false); |
| 365 | } |
| 366 | }; |
| 367 | |
| 368 | return ( |
| 369 | <SettingsPageContainer> |
| 370 | <SettingsSection id="snap-shot" title="SnapShots"> |
| 371 | <SettingsUnavailableGroup message={unavailableMessage}> |
| 372 | <SettingsRow |
| 373 | {...searchableSetting("snap-shot-enabled")} |
| 374 | description={snapShotDescription(state)} |
| 375 | status={ |
| 376 | bridge |
| 377 | ? setupBusy && !wizard |
| 378 | ? "Updating capture settings…" |
| 379 | : snapShotStatus(state, settings.snapShotEnabled) |
| 380 | : undefined |
| 381 | } |
| 382 | control={ |
| 383 | <> |
| 384 | {settings.snapShotEnabled && |
| 385 | !snapShotSetupComplete(state, settings.snapShotIncludeAccessibility) ? ( |
| 386 | <Button |
| 387 | size="xs" |
| 388 | variant="outline" |
| 389 | disabled={setupBusy} |
| 390 | onClick={() => void openSetup()} |
| 391 | > |
| 392 | {snapShotSetupButtonLabel(state)} |
| 393 | </Button> |
| 394 | ) : null} |
| 395 | <Switch |
| 396 | checked={settings.snapShotEnabled || Boolean(wizard)} |
| 397 | disabled={!captureAvailable || setupBusy} |
| 398 | aria-label="Enable snapshots" |
| 399 | onCheckedChange={(checked) => { |
| 400 | if (!checked) void save({ snapShotEnabled: false }); |
| 401 | else if (state?.windows) void save({ snapShotEnabled: true }); |
| 402 | else void openSetup(); |
| 403 | }} |
| 404 | /> |
| 405 | </> |
| 406 | } |
| 407 | /> |
| 408 | {settings.snapShotEnabled && captureAvailable ? ( |
| 409 | <> |
| 410 | <SettingsRow |
| 411 | {...searchableSetting("snap-shot-accessibility")} |
| 412 | description="Include text and controls when the app makes them available." |
| 413 | status={snapShotAccessibilityUnavailableMessage(state)} |
| 414 | control={ |
| 415 | <Switch |
| 416 | checked={ |
| 417 | !snapShotAccessibilityUnavailableMessage(state) && |
| 418 | settings.snapShotIncludeAccessibility |
| 419 | } |
| 420 | disabled={ |
| 421 | !captureAvailable || Boolean(snapShotAccessibilityUnavailableMessage(state)) |
| 422 | } |
| 423 | aria-label="Include app text in snapshots" |
| 424 | onCheckedChange={(checked) => void saveIncludeAccessibility(checked)} |
| 425 | /> |
| 426 | } |
| 427 | /> |
| 428 | <SettingsRow |
| 429 | {...searchableSetting("snap-shot-shortcut")} |
| 430 | description={ |
| 431 | state?.linuxBackend === "picker" |
| 432 | ? "Choose a window to capture from any app." |
| 433 | : "Capture the window you're using without switching apps." |
| 434 | } |
| 435 | status={managedShortcut ? undefined : shortcutStatus} |
| 436 | control={ |
| 437 | managedShortcut ? ( |
| 438 | <Button |
| 439 | size="xs" |
| 440 | variant="outline" |
| 441 | disabled={setupBusy} |
| 442 | onClick={() => void openSetup("shortcut")} |
| 443 | > |
| 444 | Change shortcut |
| 445 | </Button> |
| 446 | ) : ( |
| 447 | <> |
| 448 | {shortcutInput} |
| 449 | {shortcutChanged ? ( |
| 450 | <> |
| 451 | <Button |
| 452 | size="xs" |
| 453 | disabled={!canSaveShortcut || setupBusy} |
| 454 | onClick={() => void saveShortcut()} |
| 455 | > |
| 456 | {setupBusy ? "Saving…" : "Save"} |
| 457 | </Button> |
| 458 | <Button |
| 459 | size="xs" |
| 460 | variant="ghost" |
| 461 | disabled={setupBusy} |
| 462 | onClick={() => { |
| 463 | stopRecording(); |
| 464 | shortcutCheckIdRef.current++; |
| 465 | setCandidate(savedShortcut); |
| 466 | setShortcutCheck({ status: "idle", availability: null }); |
| 467 | }} |
| 468 | > |
| 469 | Cancel |
| 470 | </Button> |
| 471 | </> |
| 472 | ) : state?.mode === "portal" && |
| 473 | state.shortcutCanRetry !== false && |
| 474 | !isModifierPairShortcut(savedShortcut) ? ( |
| 475 | <Button |
| 476 | size="xs" |
| 477 | variant="ghost" |
| 478 | disabled={setupBusy || state.shortcutPending} |
| 479 | onClick={() => void setup("retry-shortcut")} |
| 480 | > |
| 481 | Shortcut permissions |
| 482 | </Button> |
| 483 | ) : null} |
| 484 | </> |
| 485 | ) |
| 486 | } |
| 487 | /> |
| 488 | <SettingsRow |
| 489 | {...searchableSetting("snap-shot-sound")} |
| 490 | description="Choose the sound played when capture starts." |
| 491 | control={ |
| 492 | <Menu> |
| 493 | <MenuTrigger |
| 494 | aria-label={"Snapshot sound: " + soundLabel} |
| 495 | render={<SelectButton size="sm" />} |
| 496 | className="w-auto min-w-0" |
| 497 | disabled={!captureAvailable} |
| 498 | > |
| 499 | {soundSelection === "off" ? ( |
| 500 | "Off" |
| 501 | ) : soundSelection === "soft-pop" ? ( |
| 502 | <> |
| 503 | Whoosh <span className="text-muted-foreground">(Default)</span> |
| 504 | </> |
| 505 | ) : ( |
| 506 | "Click" |
| 507 | )} |
| 508 | </MenuTrigger> |
| 509 | <MenuPopup align="end"> |
| 510 | <MenuRadioGroup |
| 511 | onValueChange={(value) => |
| 512 | void save(snapShotSoundPatch(value as SnapShotSoundSelection)) |
| 513 | } |
| 514 | value={soundSelection} |
| 515 | > |
| 516 | <MenuRadioItem closeOnClick value="off"> |
| 517 | Off |
| 518 | </MenuRadioItem> |
| 519 | <div className={soundOptionRowClassName}> |
| 520 | <MenuRadioItem closeOnClick value="soft-pop"> |
| 521 | Whoosh <span className="text-muted-foreground">(Default)</span> |
| 522 | </MenuRadioItem> |
| 523 | <MenuItem |
| 524 | aria-label="Play Whoosh" |
| 525 | closeOnClick={false} |
| 526 | onClick={() => playSnapShotSound("soft-pop")} |
| 527 | > |
| 528 | <PlayIcon /> |
| 529 | </MenuItem> |
| 530 | </div> |
| 531 | <div className={soundOptionRowClassName}> |
| 532 | <MenuRadioItem closeOnClick value="camera-shutter"> |
| 533 | Click |
| 534 | </MenuRadioItem> |
| 535 | <MenuItem |
| 536 | aria-label="Play Click" |
| 537 | closeOnClick={false} |
| 538 | onClick={() => playSnapShotSound("camera-shutter")} |
| 539 | > |
| 540 | <PlayIcon /> |
| 541 | </MenuItem> |
| 542 | </div> |
| 543 | </MenuRadioGroup> |
| 544 | </MenuPopup> |
| 545 | </Menu> |
| 546 | } |
| 547 | /> |
| 548 | <SettingsRow |
| 549 | {...searchableSetting("snap-shot-flash")} |
| 550 | description="Show a gentle cue on the captured window." |
| 551 | status={feedbackUnavailable} |
| 552 | control={ |
| 553 | <Switch |
| 554 | checked={!feedbackUnavailable && settings.snapShotFlash} |
| 555 | disabled={!captureAvailable || Boolean(feedbackUnavailable)} |
| 556 | aria-label="Flash captured window" |
| 557 | onCheckedChange={(checked) => void save({ snapShotFlash: checked })} |
| 558 | /> |
| 559 | } |
| 560 | /> |
| 561 | <SettingsRow |
| 562 | {...searchableSetting("snap-shot-animations")} |
| 563 | description="Animate captured windows into your draft." |
| 564 | status={feedbackUnavailable} |
| 565 | control={ |
| 566 | <Switch |
| 567 | checked={!feedbackUnavailable && settings.snapShotAnimations} |
| 568 | disabled={!captureAvailable || Boolean(feedbackUnavailable)} |
| 569 | aria-label="Animate snapshots" |
| 570 | onCheckedChange={(checked) => void save({ snapShotAnimations: checked })} |
| 571 | /> |
| 572 | } |
| 573 | /> |
| 574 | </> |
| 575 | ) : null} |
| 576 | </SettingsUnavailableGroup> |
| 577 | </SettingsSection> |
| 578 | {wizard && state ? ( |
| 579 | <SnapShotSetupDialog |
| 580 | state={state} |
| 581 | initialStep={wizard.initialStep} |
| 582 | wasEnabled={wizard.wasEnabled} |
| 583 | includeAccessibility={settings.snapShotIncludeAccessibility} |
| 584 | busy={setupBusy} |
| 585 | error={setupError?.message ?? null} |
| 586 | shortcutInput={shortcutInput} |
| 587 | shortcutStatus={shortcutStatus} |
| 588 | shortcutChanged={shortcutChanged} |
| 589 | canSaveShortcut={canSaveShortcut} |
| 590 | onSaveShortcut={saveShortcut} |
| 591 | onEnable={enableForSetup} |
| 592 | onAction={setup} |
| 593 | onRefresh={refreshState} |
| 594 | onClose={closeSetup} |
| 595 | onLeaveStep={stopRecording} |
| 596 | /> |
| 597 | ) : null} |
| 598 | </SettingsPageContainer> |
| 599 | ); |
| 600 | } |