Skip to content

fix: improve click UX and styling for Auth Token page #11863

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 32 commits into from
Feb 1, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
7e4d964
wip: commit progress for clipboard update
Parkreiner Jan 26, 2024
ece3f6d
wip: push more progress
Parkreiner Jan 26, 2024
bf6e73a
chore: finish initial version of useClipboard revamp
Parkreiner Jan 26, 2024
697bdf0
refactor: update API query to use newer RQ patterns
Parkreiner Jan 26, 2024
9b881c0
fix: update importers of useClipboard
Parkreiner Jan 26, 2024
ccdca08
fix: increase clickable area of CodeExample
Parkreiner Jan 26, 2024
a7c7cc7
fix: update styles for CliAuthPageView
Parkreiner Jan 26, 2024
163db92
fix: resolve issue with ref re-routing
Parkreiner Jan 26, 2024
38b80a8
docs: update comments for clarity
Parkreiner Jan 27, 2024
92f94d7
wip: commit progress on clipboard tests
Parkreiner Jan 27, 2024
e0e5e6f
chore: add extra test case for referential stability
Parkreiner Jan 27, 2024
0ca1558
wip: disable test stub to avoid breaking CI
Parkreiner Jan 27, 2024
88b96df
wip: add test case for tab-switching
Parkreiner Jan 27, 2024
15feb14
feat: finish changes
Parkreiner Jan 27, 2024
e3feffc
fix: improve styling for strong text
Parkreiner Jan 27, 2024
3ec5196
fix: make sure period doesn't break onto separate line
Parkreiner Jan 27, 2024
cf2d179
fix: make center styling more friendly to screen readers
Parkreiner Jan 27, 2024
98bd1af
refactor: clean up mocking implementation
Parkreiner Jan 28, 2024
400e07c
fix: resolve security concern for clipboard text
Parkreiner Jan 28, 2024
3307432
fix: update CodeExample to obscure text when appropriate
Parkreiner Jan 28, 2024
f10134f
fix: apply secret changes to relevant code examples
Parkreiner Jan 28, 2024
c4469f3
refactor: simplify code for obfuscating text
Parkreiner Jan 28, 2024
d8b6727
fix: partially revert clipboard changes
Parkreiner Jan 29, 2024
6157049
fix: clean up page styling further
Parkreiner Jan 29, 2024
89c74da
fix: remove duplicate property identifier
Parkreiner Jan 29, 2024
b95cfd6
refactor: rename variables for clarity
Parkreiner Feb 1, 2024
4ab6326
fix: simplify/revert CopyButton component design
Parkreiner Feb 1, 2024
778560f
fix: update how dummy input is hidden from page
Parkreiner Feb 1, 2024
cf53114
fix: remove unused onClick handler prop
Parkreiner Feb 1, 2024
97fed6f
fix: resolve unused import
Parkreiner Feb 1, 2024
83febe2
Merge branch 'main' into mes/clipboard-fix
Parkreiner Feb 1, 2024
fb0f4b7
fix: opt code examples out of secret behavior
Parkreiner Feb 1, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
fix: partially revert clipboard changes
  • Loading branch information
Parkreiner committed Jan 29, 2024
commit d8b672733903a101ae6988aaeca544f77572c591
136 changes: 0 additions & 136 deletions site/src/hooks/useClipboard.test.ts

This file was deleted.

186 changes: 35 additions & 151 deletions site/src/hooks/useClipboard.ts
Original file line number Diff line number Diff line change
@@ -1,178 +1,62 @@
import { useCallback, useEffect, useState } from "react";
import { useEffectEvent } from "./hookPolyfills";
import { useEffect, useRef, useState } from "react";

type UseClipboardResult = Readonly<{
isCopied: boolean;
copyToClipboard: () => Promise<void>;
}>;

export const useClipboard = (textToCopy: string): UseClipboardResult => {
// Can't initialize clipboardText with a more specific value because reading
// is an async operation
const [clipboardText, setClipboardText] = useState("");

// Copy events have a ClipboardEvent associated with them, but sadly, the
// event only gives you information about what caused the event, not the new
// data that's just been copied. Have to use same handler for all operations
const syncClipboardToState = useEffectEvent(async () => {
const result = await readFromClipboard();
setClipboardText((current) => (result.success ? result.value : current));
});
const [isCopied, setIsCopied] = useState(false);
const timeoutIdRef = useRef<number | undefined>();

useEffect(() => {
// Focus event handles case where user navigates to a different tab, copies
// new text, and then comes back to Coder
window.addEventListener("focus", syncClipboardToState);
window.addEventListener("copy", syncClipboardToState);
void syncClipboardToState();

return () => {
window.removeEventListener("focus", syncClipboardToState);
window.removeEventListener("copy", syncClipboardToState);
};
}, [syncClipboardToState]);

const copyToClipboard = useCallback(async () => {
const result = await writeToClipboard(textToCopy);
if (result.success) {
void syncClipboardToState();
return;
}
}, [syncClipboardToState, textToCopy]);

return {
copyToClipboard,
isCopied: textToCopy === clipboardText,
};
};

type VoidResult = Readonly<
{ success: true; error: null } | { success: false; error: Error }
>;

type ResultWithData<T = unknown> = Readonly<
| { success: true; value: T; error: null }
| { success: false; value: null; error: Error }
>;

type Result<T = unknown> = void extends T ? VoidResult : ResultWithData<T>;

async function readFromClipboard(): Promise<Result<string>> {
// This is mainly here for the sake of being exhaustive, but the main thing it
// helps with is suppressing error messages when Vite does HMR refreshes in
// dev mode
if (!document.hasFocus()) {
return {
success: false,
value: null,
error: new Error(
"Security error - clipboard read queued while tab was not active",
),
};
}

try {
// navigator.clipboard is a newer API. It should be defined in most browsers
// nowadays, but there's a fallback if not
if (typeof window?.navigator?.clipboard?.readText === "function") {
return {
success: true,
value: await window.navigator.clipboard.readText(),
error: null,
};
}

const { isExecSupported, value } = simulateClipboard("read");
if (!isExecSupported) {
throw new Error(
"document.execCommand has been removed for the user's browser, but they do not have access to newer API",
);
}
const clearIdsOnUnmount = () => window.clearTimeout(timeoutIdRef.current);
return clearIdsOnUnmount;
}, []);

return {
success: true,
value: value,
error: null,
};
} catch (err) {
// Only expected error not covered by function logic is the user not
// granting the webpage permission to access the clipboard
const flattenedError =
err instanceof Error
? err
: new Error("Unknown error thrown while reading");

return {
success: false,
value: null,
error: flattenedError,
};
}
}

// Comments for this function mirror the ones for readFromClipboard
async function writeToClipboard(textToCopy: string): Promise<Result<void>> {
if (!document.hasFocus()) {
return {
success: false,
error: new Error(
"Security error - clipboard read queued while tab was not active",
),
};
}

try {
if (typeof window?.navigator?.clipboard?.writeText === "function") {
const copyToClipboard = async () => {
try {
await window.navigator.clipboard.writeText(textToCopy);
return { success: true, error: null };
}

const { isExecSupported } = simulateClipboard("write");
if (!isExecSupported) {
throw new Error(
"document.execCommand has been removed for the user's browser, but they do not have access to newer API",
);
setIsCopied(true);
timeoutIdRef.current = window.setTimeout(() => {
setIsCopied(false);
}, 1000);
} catch (err) {
const isExecSupported = simulateClipboardWrite();
if (isExecSupported) {
setIsCopied(true);
timeoutIdRef.current = window.setTimeout(() => {
setIsCopied(false);
}, 1000);
} else {
const wrappedErr = new Error(
"copyToClipboard: failed to copy text to clipboard",
);
if (err instanceof Error) {
wrappedErr.stack = err.stack;
}
console.error(wrappedErr);
}
}
};

return { success: true, error: null };
} catch (err) {
const flattenedError =
err instanceof Error
? err
: new Error("Unknown error thrown while reading");

return {
success: false,
error: flattenedError,
};
}
}

type SimulateClipboardResult = Readonly<{
isExecSupported: boolean;
value: string;
}>;
return { isCopied: isCopied, copyToClipboard };
};

function simulateClipboard(
operation: "read" | "write",
): SimulateClipboardResult {
// Absolutely cartoonish logic, but it's how you do things with the exec API
function simulateClipboardWrite(): boolean {
const previousFocusTarget = document.activeElement;
const dummyInput = document.createElement("input");
dummyInput.style.visibility = "hidden";
document.body.appendChild(dummyInput);
dummyInput.focus();
dummyInput.select();

// Confusingly, you want to use the command opposite of what you actually want
// to do to interact with the execCommand method
const command = operation === "read" ? "paste" : "copy";
const isExecSupported = document.execCommand(command);
const value = dummyInput.value;
const isExecSupported = document.execCommand("copy");
dummyInput.remove();

if (previousFocusTarget instanceof HTMLElement) {
previousFocusTarget.focus();
}

return { isExecSupported, value };
return isExecSupported;
}