| author | |
| committer | |
| log | b88873508e8dedbff772b9835604d66c8c72c17c |
| tree | acc843e190dff2cbc20275ab5fbdcf5132bbc700 |
| parent | 58053d146b1ea7201884867d1543ccbbc4c05ffd |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>12 files changed, 261 insertions(+), 39 deletions(-)
apps/mobile/src/app/settings/environments.tsx+2-2| ... | @@ -397,7 +397,7 @@ function CloudEnvironmentRowShell(props: { | ... | @@ -397,7 +397,7 @@ function CloudEnvironmentRowShell(props: { |
| 397 | className={cn("text-xs leading-[16px] underline", statusClassName)} | 397 | className={cn("text-xs leading-[16px] underline", statusClassName)} |
| 398 | onLongPress={(event) => { | 398 | onLongPress={(event) => { |
| 399 | event.stopPropagation(); | 399 | event.stopPropagation(); |
| 400 | copyTextWithHaptic(errorTraceId); | 400 | copyTextWithHaptic(errorTraceId, { target: "connection-trace-id" }); |
| 401 | }} | 401 | }} |
| 402 | onPress={(event) => { | 402 | onPress={(event) => { |
| 403 | event.stopPropagation(); | 403 | event.stopPropagation(); |
| ... | @@ -441,7 +441,7 @@ function CopyTraceIdButton(props: { readonly traceId: string }) { | ... | @@ -441,7 +441,7 @@ function CopyTraceIdButton(props: { readonly traceId: string }) { |
| 441 | <Pressable | 441 | <Pressable |
| 442 | accessibilityRole="button" | 442 | accessibilityRole="button" |
| 443 | onPress={() => { | 443 | onPress={() => { |
| 444 | copyTextWithHaptic(props.traceId); | 444 | copyTextWithHaptic(props.traceId, { target: "connection-trace-id" }); |
| 445 | }} | 445 | }} |
| 446 | className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70" | 446 | className="self-start flex-row items-center gap-1.5 rounded-full bg-subtle px-3 py-2 active:opacity-70" |
| 447 | > | 447 | > |
apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx+1-1| ... | @@ -101,7 +101,7 @@ export function ConnectionEnvironmentRow(props: { | ... | @@ -101,7 +101,7 @@ export function ConnectionEnvironmentRow(props: { |
| 101 | className="underline" | 101 | className="underline" |
| 102 | onLongPress={(event) => { | 102 | onLongPress={(event) => { |
| 103 | event.stopPropagation(); | 103 | event.stopPropagation(); |
| 104 | copyTextWithHaptic(statusTraceId); | 104 | copyTextWithHaptic(statusTraceId, { target: "connection-trace-id" }); |
| 105 | }} | 105 | }} |
| 106 | onPress={(event) => { | 106 | onPress={(event) => { |
| 107 | event.stopPropagation(); | 107 | event.stopPropagation(); |
apps/mobile/src/features/connection/EnvironmentConnectionNotice.tsx+5-1| ... | @@ -85,7 +85,11 @@ export function EnvironmentConnectionNotice(props: { | ... | @@ -85,7 +85,11 @@ export function EnvironmentConnectionNotice(props: { |
| 85 | accessibilityHint="Copies the trace ID" | 85 | accessibilityHint="Copies the trace ID" |
| 86 | accessibilityRole="button" | 86 | accessibilityRole="button" |
| 87 | className="underline decoration-dotted" | 87 | className="underline decoration-dotted" |
| 88 | onPress={() => copyTextWithHaptic(props.connection.traceId!)} | 88 | onPress={() => |
| 89 | copyTextWithHaptic(props.connection.traceId!, { | ||
| 90 | target: "connection-trace-id", | ||
| 91 | }) | ||
| 92 | } | ||
| 89 | > | 93 | > |
| 90 | {props.connection.traceId} | 94 | {props.connection.traceId} |
| 91 | </Text> | 95 | </Text> |
apps/mobile/src/features/threads/ThreadFeed.tsx+5-3| ... | @@ -1,4 +1,3 @@ | ... | @@ -1,4 +1,3 @@ |
| 1 | import * as Clipboard from "expo-clipboard"; | ||
| 2 | import * as Haptics from "expo-haptics"; | 1 | import * as Haptics from "expo-haptics"; |
| 3 | import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard"; | 2 | import { KeyboardAvoidingLegendList } from "@legendapp/list/keyboard"; |
| 4 | import { type LegendListRef } from "@legendapp/list/react-native"; | 3 | import { type LegendListRef } from "@legendapp/list/react-native"; |
| ... | @@ -31,6 +30,7 @@ import { TouchableOpacity } from "react-native-gesture-handler"; | ... | @@ -31,6 +30,7 @@ import { TouchableOpacity } from "react-native-gesture-handler"; |
| 31 | import ImageViewing from "react-native-image-viewing"; | 30 | import ImageViewing from "react-native-image-viewing"; |
| 32 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 31 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 33 | import { useThemeColor } from "../../lib/useThemeColor"; | 32 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 33 | import { copyTextWithHaptic } from "../../lib/copyTextWithHaptic"; | ||
| 34 | import { | 34 | import { |
| 35 | hasNativeSelectableMarkdownText, | 35 | hasNativeSelectableMarkdownText, |
| 36 | SelectableMarkdownText, | 36 | SelectableMarkdownText, |
| ... | @@ -1321,8 +1321,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { | ... | @@ -1321,8 +1321,10 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { |
| 1321 | }, []); | 1321 | }, []); |
| 1322 | 1322 | ||
| 1323 | const onCopyWorkRow = useCallback((rowId: string, value: string) => { | 1323 | const onCopyWorkRow = useCallback((rowId: string, value: string) => { |
| 1324 | void Clipboard.setStringAsync(value); | 1324 | copyTextWithHaptic(value, { |
| 1325 | void Haptics.selectionAsync(); | 1325 | target: "thread-work-row", |
| 1326 | feedback: "selection", | ||
| 1327 | }); | ||
| 1326 | setInteractionState((current) => ({ ...current, copiedRowId: rowId })); | 1328 | setInteractionState((current) => ({ ...current, copiedRowId: rowId })); |
| 1327 | if (copyFeedbackTimeoutRef.current) { | 1329 | if (copyFeedbackTimeoutRef.current) { |
| 1328 | clearTimeout(copyFeedbackTimeoutRef.current); | 1330 | clearTimeout(copyFeedbackTimeoutRef.current); |
apps/mobile/src/lib/copyTextWithHaptic.test.ts+58-2| ... | @@ -1,7 +1,8 @@ | ... | @@ -1,7 +1,8 @@ |
| 1 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; | 1 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 2 | 2 | ||
| 3 | const mocks = vi.hoisted(() => ({ | 3 | const mocks = vi.hoisted(() => ({ |
| 4 | impactAsync: vi.fn(), | 4 | impactAsync: vi.fn(), |
| 5 | selectionAsync: vi.fn(), | ||
| 5 | setStringAsync: vi.fn(), | 6 | setStringAsync: vi.fn(), |
| 6 | })); | 7 | })); |
| 7 | 8 | ||
| ... | @@ -14,15 +15,25 @@ vi.mock("expo-haptics", () => ({ | ... | @@ -14,15 +15,25 @@ vi.mock("expo-haptics", () => ({ |
| 14 | Light: "light", | 15 | Light: "light", |
| 15 | }, | 16 | }, |
| 16 | impactAsync: mocks.impactAsync, | 17 | impactAsync: mocks.impactAsync, |
| 18 | selectionAsync: mocks.selectionAsync, | ||
| 17 | })); | 19 | })); |
| 18 | 20 | ||
| 19 | import { copyTextWithHaptic } from "./copyTextWithHaptic"; | 21 | import { |
| 22 | CopyTextClipboardWriteError, | ||
| 23 | CopyTextHapticFeedbackError, | ||
| 24 | copyTextWithHaptic, | ||
| 25 | } from "./copyTextWithHaptic"; | ||
| 20 | 26 | ||
| 21 | describe("copyTextWithHaptic", () => { | 27 | describe("copyTextWithHaptic", () => { |
| 22 | beforeEach(() => { | 28 | beforeEach(() => { |
| 23 | vi.clearAllMocks(); | 29 | vi.clearAllMocks(); |
| 24 | mocks.setStringAsync.mockReturnValue(new Promise<void>(() => undefined)); | 30 | mocks.setStringAsync.mockReturnValue(new Promise<void>(() => undefined)); |
| 25 | mocks.impactAsync.mockResolvedValue(undefined); | 31 | mocks.impactAsync.mockResolvedValue(undefined); |
| 32 | mocks.selectionAsync.mockResolvedValue(undefined); | ||
| 33 | }); | ||
| 34 | |||
| 35 | afterEach(() => { | ||
| 36 | vi.restoreAllMocks(); | ||
| 26 | }); | 37 | }); |
| 27 | 38 | ||
| 28 | it("triggers haptic feedback without waiting for the clipboard promise", () => { | 39 | it("triggers haptic feedback without waiting for the clipboard promise", () => { |
| ... | @@ -31,4 +42,49 @@ describe("copyTextWithHaptic", () => { | ... | @@ -31,4 +42,49 @@ describe("copyTextWithHaptic", () => { |
| 31 | expect(mocks.setStringAsync).toHaveBeenCalledWith("trace-123"); | 42 | expect(mocks.setStringAsync).toHaveBeenCalledWith("trace-123"); |
| 32 | expect(mocks.impactAsync).toHaveBeenCalledWith("light"); | 43 | expect(mocks.impactAsync).toHaveBeenCalledWith("light"); |
| 33 | }); | 44 | }); |
| 45 | |||
| 46 | it("preserves selection feedback for thread work rows", () => { | ||
| 47 | copyTextWithHaptic("work output", { | ||
| 48 | target: "thread-work-row", | ||
| 49 | feedback: "selection", | ||
| 50 | }); | ||
| 51 | |||
| 52 | expect(mocks.setStringAsync).toHaveBeenCalledWith("work output"); | ||
| 53 | expect(mocks.selectionAsync).toHaveBeenCalledOnce(); | ||
| 54 | expect(mocks.impactAsync).not.toHaveBeenCalled(); | ||
| 55 | }); | ||
| 56 | |||
| 57 | it("reports structured failures without including clipboard contents", async () => { | ||
| 58 | const clipboardCause = new Error("native clipboard failure"); | ||
| 59 | const hapticCause = new Error("native haptic failure"); | ||
| 60 | const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); | ||
| 61 | mocks.setStringAsync.mockRejectedValueOnce(clipboardCause); | ||
| 62 | mocks.impactAsync.mockRejectedValueOnce(hapticCause); | ||
| 63 | |||
| 64 | copyTextWithHaptic("secret clipboard contents", { target: "connection-trace-id" }); | ||
| 65 | |||
| 66 | await vi.waitFor(() => { | ||
| 67 | expect(consoleError).toHaveBeenCalledTimes(2); | ||
| 68 | }); | ||
| 69 | |||
| 70 | const failures = consoleError.mock.calls.map(([failure]) => failure); | ||
| 71 | const clipboardError = failures.find( | ||
| 72 | (failure) => failure instanceof CopyTextClipboardWriteError, | ||
| 73 | ); | ||
| 74 | expect(clipboardError).toBeInstanceOf(CopyTextClipboardWriteError); | ||
| 75 | expect(clipboardError).toMatchObject({ | ||
| 76 | target: "connection-trace-id", | ||
| 77 | cause: clipboardCause, | ||
| 78 | }); | ||
| 79 | expect((clipboardError as Error).message).not.toContain("secret clipboard contents"); | ||
| 80 | |||
| 81 | const hapticError = failures.find((failure) => failure instanceof CopyTextHapticFeedbackError); | ||
| 82 | expect(hapticError).toBeInstanceOf(CopyTextHapticFeedbackError); | ||
| 83 | expect(hapticError).toMatchObject({ | ||
| 84 | target: "connection-trace-id", | ||
| 85 | feedback: "light-impact", | ||
| 86 | cause: hapticCause, | ||
| 87 | }); | ||
| 88 | expect((hapticError as Error).message).not.toContain("secret clipboard contents"); | ||
| 89 | }); | ||
| 34 | }); | 90 | }); |
apps/mobile/src/lib/copyTextWithHaptic.ts+66-3| ... | @@ -1,7 +1,70 @@ | ... | @@ -1,7 +1,70 @@ |
| 1 | import * as Schema from "effect/Schema"; | ||
| 1 | import * as Clipboard from "expo-clipboard"; | 2 | import * as Clipboard from "expo-clipboard"; |
| 2 | import * as Haptics from "expo-haptics"; | 3 | import * as Haptics from "expo-haptics"; |
| 3 | 4 | ||
| 4 | export function copyTextWithHaptic(value: string): void { | 5 | export class CopyTextClipboardWriteError extends Schema.TaggedErrorClass<CopyTextClipboardWriteError>()( |
| 5 | void Clipboard.setStringAsync(value); | 6 | "CopyTextClipboardWriteError", |
| 6 | void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); | 7 | { |
| 8 | target: Schema.String, | ||
| 9 | cause: Schema.Defect(), | ||
| 10 | }, | ||
| 11 | ) { | ||
| 12 | override get message(): string { | ||
| 13 | return `Failed to copy ${this.target} to the clipboard.`; | ||
| 14 | } | ||
| 15 | } | ||
| 16 | |||
| 17 | export class CopyTextHapticFeedbackError extends Schema.TaggedErrorClass<CopyTextHapticFeedbackError>()( | ||
| 18 | "CopyTextHapticFeedbackError", | ||
| 19 | { | ||
| 20 | target: Schema.String, | ||
| 21 | feedback: Schema.Literals(["light-impact", "selection"]), | ||
| 22 | cause: Schema.Defect(), | ||
| 23 | }, | ||
| 24 | ) { | ||
| 25 | override get message(): string { | ||
| 26 | return `Failed to trigger ${this.feedback} haptic feedback after copying ${this.target}.`; | ||
| 27 | } | ||
| 28 | } | ||
| 29 | |||
| 30 | export function copyTextWithHaptic( | ||
| 31 | value: string, | ||
| 32 | options: { | ||
| 33 | readonly target?: string; | ||
| 34 | readonly feedback?: "light-impact" | "selection"; | ||
| 35 | } = {}, | ||
| 36 | ): void { | ||
| 37 | const target = options.target ?? "text"; | ||
| 38 | const feedback = options.feedback ?? "light-impact"; | ||
| 39 | |||
| 40 | void (async () => { | ||
| 41 | try { | ||
| 42 | await Clipboard.setStringAsync(value); | ||
| 43 | } catch (cause) { | ||
| 44 | console.error( | ||
| 45 | new CopyTextClipboardWriteError({ | ||
| 46 | target, | ||
| 47 | cause, | ||
| 48 | }), | ||
| 49 | ); | ||
| 50 | } | ||
| 51 | })(); | ||
| 52 | |||
| 53 | void (async () => { | ||
| 54 | try { | ||
| 55 | if (feedback === "selection") { | ||
| 56 | await Haptics.selectionAsync(); | ||
| 57 | } else { | ||
| 58 | await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); | ||
| 59 | } | ||
| 60 | } catch (cause) { | ||
| 61 | console.error( | ||
| 62 | new CopyTextHapticFeedbackError({ | ||
| 63 | target, | ||
| 64 | feedback, | ||
| 65 | cause, | ||
| 66 | }), | ||
| 67 | ); | ||
| 68 | } | ||
| 69 | })(); | ||
| 7 | } | 70 | } |
apps/web/src/components/PlanSidebar.tsx+1-1| ... | @@ -83,7 +83,7 @@ const PlanSidebar = memo(function PlanSidebar({ | ... | @@ -83,7 +83,7 @@ const PlanSidebar = memo(function PlanSidebar({ |
| 83 | const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { | 83 | const writeProjectFile = useAtomCommand(projectEnvironment.writeFile, { |
| 84 | reportFailure: false, | 84 | reportFailure: false, |
| 85 | }); | 85 | }); |
| 86 | const { copyToClipboard, isCopied } = useCopyToClipboard(); | 86 | const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "plan" }); |
| 87 | 87 | ||
| 88 | const planMarkdown = activeProposedPlan?.planMarkdown ?? null; | 88 | const planMarkdown = activeProposedPlan?.planMarkdown ?? null; |
| 89 | const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; | 89 | const displayedPlanMarkdown = planMarkdown ? stripDisplayedPlanMarkdown(planMarkdown) : null; |
apps/web/src/components/chat/ProposedPlanCard.tsx+1| ... | @@ -54,6 +54,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ | ... | @@ -54,6 +54,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ |
| 54 | reportFailure: false, | 54 | reportFailure: false, |
| 55 | }); | 55 | }); |
| 56 | const { copyToClipboard, isCopied } = useCopyToClipboard({ | 56 | const { copyToClipboard, isCopied } = useCopyToClipboard({ |
| 57 | target: "plan", | ||
| 57 | onError: (error) => { | 58 | onError: (error) => { |
| 58 | toastManager.add( | 59 | toastManager.add( |
| 59 | stackedThreadToast({ | 60 | stackedThreadToast({ |
apps/web/src/components/settings/DiagnosticsSettings.tsx+6-11| ... | @@ -32,6 +32,7 @@ import { | ... | @@ -32,6 +32,7 @@ import { |
| 32 | } from "../../state/server"; | 32 | } from "../../state/server"; |
| 33 | import { shellEnvironment } from "../../state/shell"; | 33 | import { shellEnvironment } from "../../state/shell"; |
| 34 | import { usePrimaryEnvironment } from "../../state/environments"; | 34 | import { usePrimaryEnvironment } from "../../state/environments"; |
| 35 | import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; | ||
| 35 | import { Button } from "../ui/button"; | 36 | import { Button } from "../ui/button"; |
| 36 | import { ScrollArea } from "../ui/scroll-area"; | 37 | import { ScrollArea } from "../ui/scroll-area"; |
| 37 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | 38 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; |
| ... | @@ -246,16 +247,10 @@ function DiagnosticsTable({ | ... | @@ -246,16 +247,10 @@ function DiagnosticsTable({ |
| 246 | } | 247 | } |
| 247 | 248 | ||
| 248 | function TraceIdCell({ traceId }: { traceId: string }) { | 249 | function TraceIdCell({ traceId }: { traceId: string }) { |
| 249 | const [copied, setCopied] = useState(false); | 250 | const { copyToClipboard, isCopied: copied } = useCopyToClipboard({ |
| 250 | const copyTraceId = useCallback(() => { | 251 | target: "trace ID", |
| 251 | void navigator.clipboard | 252 | timeout: 1_200, |
| 252 | ?.writeText(traceId) | 253 | }); |
| 253 | .then(() => { | ||
| 254 | setCopied(true); | ||
| 255 | window.setTimeout(() => setCopied(false), 1_200); | ||
| 256 | }) | ||
| 257 | .catch(() => undefined); | ||
| 258 | }, [traceId]); | ||
| 259 | 254 | ||
| 260 | return ( | 255 | return ( |
| 261 | <div className="flex w-full min-w-0 max-w-full items-center gap-2"> | 256 | <div className="flex w-full min-w-0 max-w-full items-center gap-2"> |
| ... | @@ -281,7 +276,7 @@ function TraceIdCell({ traceId }: { traceId: string }) { | ... | @@ -281,7 +276,7 @@ function TraceIdCell({ traceId }: { traceId: string }) { |
| 281 | type="button" | 276 | type="button" |
| 282 | className="inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" | 277 | className="inline-flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground" |
| 283 | aria-label={copied ? "Copied trace ID" : "Copy trace ID"} | 278 | aria-label={copied ? "Copied trace ID" : "Copy trace ID"} |
| 284 | onClick={copyTraceId} | 279 | onClick={() => copyToClipboard(traceId)} |
| 285 | > | 280 | > |
| 286 | <CopyIcon className="size-3" /> | 281 | <CopyIcon className="size-3" /> |
| 287 | </button> | 282 | </button> |
apps/web/src/components/ui/toast.tsx+1-1| ... | @@ -117,7 +117,7 @@ function handleToastDismissClick( | ... | @@ -117,7 +117,7 @@ function handleToastDismissClick( |
| 117 | } | 117 | } |
| 118 | 118 | ||
| 119 | function CopyErrorButton({ text }: { text: string }) { | 119 | function CopyErrorButton({ text }: { text: string }) { |
| 120 | const { copyToClipboard, isCopied } = useCopyToClipboard(); | 120 | const { copyToClipboard, isCopied } = useCopyToClipboard({ target: "error-message" }); |
| 121 | const label = isCopied ? "Copied error" : "Copy error"; | 121 | const label = isCopied ? "Copied error" : "Copy error"; |
| 122 | 122 | ||
| 123 | return ( | 123 | return ( |
apps/web/src/hooks/useCopyToClipboard.test.ts created+58| ... | @@ -0,0 +1,58 @@ | ||
| 1 | import { afterEach, describe, expect, it, vi } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { | ||
| 4 | ClipboardApiUnavailableError, | ||
| 5 | ClipboardWriteError, | ||
| 6 | writeTextToClipboard, | ||
| 7 | } from "./useCopyToClipboard"; | ||
| 8 | |||
| 9 | describe("writeTextToClipboard", () => { | ||
| 10 | afterEach(() => { | ||
| 11 | vi.unstubAllGlobals(); | ||
| 12 | }); | ||
| 13 | |||
| 14 | it("reports unavailable clipboard support with structural context", async () => { | ||
| 15 | vi.stubGlobal("window", {}); | ||
| 16 | vi.stubGlobal("navigator", {}); | ||
| 17 | |||
| 18 | const error = await writeTextToClipboard("plan contents", "plan").then( | ||
| 19 | () => undefined, | ||
| 20 | (cause: unknown) => cause, | ||
| 21 | ); | ||
| 22 | |||
| 23 | expect(error).toBeInstanceOf(ClipboardApiUnavailableError); | ||
| 24 | expect(error).toMatchObject({ | ||
| 25 | target: "plan", | ||
| 26 | }); | ||
| 27 | expect((error as Error).message).not.toContain("plan contents"); | ||
| 28 | }); | ||
| 29 | |||
| 30 | it("preserves the exact clipboard failure without exposing copied contents", async () => { | ||
| 31 | const cause = new Error("browser clipboard failure"); | ||
| 32 | const writeText = vi.fn().mockRejectedValue(cause); | ||
| 33 | vi.stubGlobal("window", {}); | ||
| 34 | vi.stubGlobal("navigator", { clipboard: { writeText } }); | ||
| 35 | |||
| 36 | const error = await writeTextToClipboard("secret clipboard contents", "error-message").then( | ||
| 37 | () => undefined, | ||
| 38 | (failure: unknown) => failure, | ||
| 39 | ); | ||
| 40 | |||
| 41 | expect(writeText).toHaveBeenCalledWith("secret clipboard contents"); | ||
| 42 | expect(error).toBeInstanceOf(ClipboardWriteError); | ||
| 43 | expect(error).toMatchObject({ | ||
| 44 | target: "error-message", | ||
| 45 | cause, | ||
| 46 | }); | ||
| 47 | expect((error as Error).message).not.toContain("secret clipboard contents"); | ||
| 48 | }); | ||
| 49 | |||
| 50 | it("keeps empty values as a no-op when clipboard support is available", async () => { | ||
| 51 | const writeText = vi.fn(); | ||
| 52 | vi.stubGlobal("window", {}); | ||
| 53 | vi.stubGlobal("navigator", { clipboard: { writeText } }); | ||
| 54 | |||
| 55 | await expect(writeTextToClipboard("", "plan")).resolves.toBe(false); | ||
| 56 | expect(writeText).not.toHaveBeenCalled(); | ||
| 57 | }); | ||
| 58 | }); | ||
apps/web/src/hooks/useCopyToClipboard.ts+57-14| ... | @@ -1,11 +1,61 @@ | ... | @@ -1,11 +1,61 @@ |
| 1 | import * as React from "react"; | 1 | import * as React from "react"; |
| 2 | import * as Schema from "effect/Schema"; | ||
| 3 | |||
| 4 | export class ClipboardApiUnavailableError extends Schema.TaggedErrorClass<ClipboardApiUnavailableError>()( | ||
| 5 | "ClipboardApiUnavailableError", | ||
| 6 | { | ||
| 7 | target: Schema.String, | ||
| 8 | }, | ||
| 9 | ) { | ||
| 10 | override get message(): string { | ||
| 11 | return `Clipboard API is unavailable while copying ${this.target}.`; | ||
| 12 | } | ||
| 13 | } | ||
| 14 | |||
| 15 | export class ClipboardWriteError extends Schema.TaggedErrorClass<ClipboardWriteError>()( | ||
| 16 | "ClipboardWriteError", | ||
| 17 | { | ||
| 18 | target: Schema.String, | ||
| 19 | cause: Schema.Defect(), | ||
| 20 | }, | ||
| 21 | ) { | ||
| 22 | override get message(): string { | ||
| 23 | return `Failed to copy ${this.target} to the clipboard.`; | ||
| 24 | } | ||
| 25 | } | ||
| 26 | |||
| 27 | export async function writeTextToClipboard(value: string, target = "text") { | ||
| 28 | if ( | ||
| 29 | typeof window === "undefined" || | ||
| 30 | typeof navigator === "undefined" || | ||
| 31 | !navigator.clipboard?.writeText | ||
| 32 | ) { | ||
| 33 | throw new ClipboardApiUnavailableError({ | ||
| 34 | target, | ||
| 35 | }); | ||
| 36 | } | ||
| 37 | |||
| 38 | if (!value) return false; | ||
| 39 | |||
| 40 | try { | ||
| 41 | await navigator.clipboard.writeText(value); | ||
| 42 | return true; | ||
| 43 | } catch (cause) { | ||
| 44 | throw new ClipboardWriteError({ | ||
| 45 | target, | ||
| 46 | cause, | ||
| 47 | }); | ||
| 48 | } | ||
| 49 | } | ||
| 2 | 50 | ||
| 3 | export function useCopyToClipboard<TContext = void>({ | 51 | export function useCopyToClipboard<TContext = void>({ |
| 4 | timeout = 2000, | 52 | timeout = 2000, |
| 53 | target = "text", | ||
| 5 | onCopy, | 54 | onCopy, |
| 6 | onError, | 55 | onError, |
| 7 | }: { | 56 | }: { |
| 8 | timeout?: number; | 57 | timeout?: number; |
| 58 | target?: string; | ||
| 9 | onCopy?: (ctx: TContext) => void; | 59 | onCopy?: (ctx: TContext) => void; |
| 10 | onError?: (error: Error, ctx: TContext) => void; | 60 | onError?: (error: Error, ctx: TContext) => void; |
| 11 | } = {}): { copyToClipboard: (value: string, ctx: TContext) => void; isCopied: boolean } { | 61 | } = {}): { copyToClipboard: (value: string, ctx: TContext) => void; isCopied: boolean } { |
| ... | @@ -13,22 +63,18 @@ export function useCopyToClipboard<TContext = void>({ | ... | @@ -13,22 +63,18 @@ export function useCopyToClipboard<TContext = void>({ |
| 13 | const timeoutIdRef = React.useRef<NodeJS.Timeout | null>(null); | 63 | const timeoutIdRef = React.useRef<NodeJS.Timeout | null>(null); |
| 14 | const onCopyRef = React.useRef(onCopy); | 64 | const onCopyRef = React.useRef(onCopy); |
| 15 | const onErrorRef = React.useRef(onError); | 65 | const onErrorRef = React.useRef(onError); |
| 66 | const targetRef = React.useRef(target); | ||
| 16 | const timeoutRef = React.useRef(timeout); | 67 | const timeoutRef = React.useRef(timeout); |
| 17 | 68 | ||
| 18 | onCopyRef.current = onCopy; | 69 | onCopyRef.current = onCopy; |
| 19 | onErrorRef.current = onError; | 70 | onErrorRef.current = onError; |
| 71 | targetRef.current = target; | ||
| 20 | timeoutRef.current = timeout; | 72 | timeoutRef.current = timeout; |
| 21 | 73 | ||
| 22 | const copyToClipboard = React.useCallback((value: string, ctx: TContext): void => { | 74 | const copyToClipboard = React.useCallback((value: string, ctx: TContext): void => { |
| 23 | if (typeof window === "undefined" || !navigator.clipboard?.writeText) { | 75 | void writeTextToClipboard(value, targetRef.current).then( |
| 24 | onErrorRef.current?.(new Error("Clipboard API unavailable."), ctx); | 76 | (didCopy) => { |
| 25 | return; | 77 | if (!didCopy) return; |
| 26 | } | ||
| 27 | |||
| 28 | if (!value) return; | ||
| 29 | |||
| 30 | navigator.clipboard.writeText(value).then( | ||
| 31 | () => { | ||
| 32 | if (timeoutIdRef.current) { | 78 | if (timeoutIdRef.current) { |
| 33 | clearTimeout(timeoutIdRef.current); | 79 | clearTimeout(timeoutIdRef.current); |
| 34 | } | 80 | } |
| ... | @@ -44,11 +90,8 @@ export function useCopyToClipboard<TContext = void>({ | ... | @@ -44,11 +90,8 @@ export function useCopyToClipboard<TContext = void>({ |
| 44 | } | 90 | } |
| 45 | }, | 91 | }, |
| 46 | (error) => { | 92 | (error) => { |
| 47 | if (onErrorRef.current) { | 93 | console.error(error); |
| 48 | onErrorRef.current(error, ctx); | 94 | onErrorRef.current?.(error, ctx); |
| 49 | } else { | ||
| 50 | console.error(error); | ||
| 51 | } | ||
| 52 | }, | 95 | }, |
| 53 | ); | 96 | ); |
| 54 | }, []); | 97 | }, []); |