-
Notifications
You must be signed in to change notification settings - Fork 894
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
Changes from 1 commit
7e4d964
ece3f6d
bf6e73a
697bdf0
9b881c0
ccdca08
a7c7cc7
163db92
38b80a8
92f94d7
e0e5e6f
0ca1558
88b96df
15feb14
e3feffc
3ec5196
cf2d179
98bd1af
400e07c
3307432
f10134f
c4469f3
d8b6727
6157049
89c74da
b95cfd6
4ab6326
778560f
cf53114
97fed6f
83febe2
fb0f4b7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
- Loading branch information
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,5 @@ | ||
import { useCallback, useEffect, useState } from "react"; | ||
import { useEffectEvent } from "./hookPolyfills"; | ||
|
||
type UseClipboardResult = Readonly<{ | ||
isCopied: boolean; | ||
|
@@ -10,37 +11,34 @@ export const useClipboard = (textToCopy: string): UseClipboardResult => { | |
// is an async operation | ||
const [clipboardText, setClipboardText] = useState(""); | ||
|
||
useEffect(() => { | ||
console.log(clipboardText); | ||
}, [clipboardText]); | ||
// 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)); | ||
}); | ||
|
||
useEffect(() => { | ||
// 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 copyClipboardToState = async () => { | ||
const result = await readFromClipboard(); | ||
setClipboardText((current) => (result.success ? result.value : current)); | ||
}; | ||
|
||
// Focus event handles case where user navigates to a different tab, copies | ||
// new text, and then comes back to Coder | ||
window.addEventListener("focus", copyClipboardToState); | ||
window.addEventListener("copy", copyClipboardToState); | ||
void copyClipboardToState(); | ||
window.addEventListener("focus", syncClipboardToState); | ||
window.addEventListener("copy", syncClipboardToState); | ||
void syncClipboardToState(); | ||
|
||
return () => { | ||
window.removeEventListener("focus", copyClipboardToState); | ||
window.removeEventListener("copy", copyClipboardToState); | ||
window.removeEventListener("focus", syncClipboardToState); | ||
window.removeEventListener("copy", syncClipboardToState); | ||
}; | ||
}, []); | ||
}, [syncClipboardToState]); | ||
|
||
const copyToClipboard = useCallback(async () => { | ||
const result = await writeToClipboard(textToCopy); | ||
if (!result.success) { | ||
console.error(result.error); | ||
if (result.success) { | ||
void syncClipboardToState(); | ||
return; | ||
} | ||
}, [textToCopy]); | ||
}, [syncClipboardToState, textToCopy]); | ||
|
||
return { | ||
copyToClipboard, | ||
|
@@ -59,13 +57,16 @@ type ResultWithData<T = unknown> = Readonly< | |
|
||
type Result<T = unknown> = void extends T ? VoidResult : ResultWithData<T>; | ||
|
||
export async function readFromClipboard(): Promise<Result<string>> { | ||
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 issue - clipboard read queued while tab was not active", | ||
"Security error - clipboard read queued while tab was not active", | ||
), | ||
}; | ||
} | ||
|
@@ -109,54 +110,42 @@ export async function readFromClipboard(): Promise<Result<string>> { | |
} | ||
} | ||
|
||
export async function writeToClipboard( | ||
textToCopy: string, | ||
): Promise<Result<void>> { | ||
return { | ||
success: true, | ||
error: null, | ||
}; | ||
// Comments for this function mirror the ones for readFromClipboard | ||
async function writeToClipboard(textToCopy: string): Promise<Result<void>> { | ||
if (!document.hasFocus()) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm... why is this necessary? Thinking about the most common use case, like the user clicking the button and the value being copied, I don't see how this could happen if the document is not in focus 🤔 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I had a comment in There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do you think is there a way to improve readability without adding a comment mentioning another comment? Thinking on a named variable or function like There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, now that you mention it, I can use an assertion function. I'll get that swapped in |
||
return { | ||
success: false, | ||
error: new Error( | ||
"Security error - clipboard read queued while tab was not active", | ||
), | ||
}; | ||
} | ||
|
||
try { | ||
if (typeof window?.navigator?.clipboard?.writeText === "function") { | ||
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", | ||
); | ||
} | ||
|
||
return { success: true, error: null }; | ||
} catch (err) { | ||
const flattenedError = | ||
err instanceof Error | ||
? err | ||
: new Error("Unknown error thrown while reading"); | ||
|
||
// // Expected throw case: user's browser is old enough that it doesn't have | ||
// // the navigator API | ||
// let wrappedError: Error | null = null; | ||
// try { | ||
// await window.navigator.clipboard.writeText(textToCopy); | ||
// return { success: true, error: null }; | ||
// } catch (err) { | ||
// wrappedError = err as Error; | ||
// } | ||
|
||
// let copySuccessful = false; | ||
// if (!copySuccessful) { | ||
// const wrappedErr = new Error( | ||
// "copyToClipboard: failed to copy text to clipboard", | ||
// ); | ||
|
||
// if (err instanceof Error) { | ||
// wrappedErr.stack = err.stack; | ||
// } | ||
|
||
// console.error(wrappedErr); | ||
// } | ||
|
||
// const previousFocusTarget = document.activeElement; | ||
// const dummyInput = document.createElement("input"); | ||
// dummyInput.value = textToCopy; | ||
|
||
// document.body.appendChild(dummyInput); | ||
// dummyInput.focus(); | ||
// dummyInput.select(); | ||
|
||
// if (typeof document.execCommand !== "undefined") { | ||
// copySuccessful = document.execCommand("copy"); | ||
// } | ||
|
||
// if (previousFocusTarget instanceof HTMLElement) { | ||
// previousFocusTarget.focus(); | ||
// } | ||
|
||
// return copySuccessful; | ||
return { | ||
success: false, | ||
error: flattenedError, | ||
}; | ||
} | ||
} | ||
|
||
type SimulateClipboardResult = Readonly<{ | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did not understand this part. Why do we need to read something from the clipboard? I would expect to just write things in it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe listing the flow using bullet points would make things easier to understand for me.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's mainly for setting up a way to watch the clipboard, since there isn't an observer-based API for it, like there is for a lot of other browser APIs
The main idea is that
isCopied
is always a derived value that comes from checking whether the text passed into the hook matches the text currently in the clipboard. And the only way to check the current clipboard is by reading from it – if we only write to it, then we can't make any assumptions about what's actually in the clipboard, because it can also change from sources outside ReactSo basically, there's three main "sync cues":
In all of the cases, we don't have a direct, event-based way to check the clipboard contents to verify that the content matches. You would think that writing to the clipboard would expose that information, but the event objects don't actually contain the new clipboard text. So in all three cases, the only choice is reading from it
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why do we need to check if the content in the clipboard matches the one passed into the hook? Is there a use case in the app where this can happen?
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So, this is splitting semantic hairs, but I guess the question boils down to, "What do we want
isCopied
to represent?isCopied
answers the question, "Each time the component renders, is the text we provided into the hook copied into the clipboard?"That mismatch between the property name and actual functionality is what led me to consider a more synchronization-based solution, but maybe this did end up putting me on a weird, wild goose chase