Files
Checkmate/client/src/Components/design-elements/CopyButton.tsx
T
gorkem-bwl 84a1b994a1 fix(client): address review feedback on the copy-message change
Four issues from code review:

- The selection guard read the document-wide selection, so a leftover
  selection anywhere on the page could swallow the first click on any
  row. It now checks that the selection is inside the row that was
  clicked, with the event threaded through to both call sites.

- CopyButton's reset timer was never cleared, so it fired against an
  unmounted component when a row went away on pagination, and a rapid
  second click could not cancel the pending reset. Held in a ref and
  cleared on unmount and on re-copy.

- navigator.clipboard only exists on secure origins, and the README
  documents reaching Checkmate over plain HTTP on a LAN address. There
  the API is undefined, so every click showed "could not copy" with no
  way forward. Falls back to a hidden textarea.

- The two message cells were byte-identical, so they are now one shared
  CopyableText component.

Claude-Session: https://claude.ai/code/session_01AbYLvwpEaZFXktHVcqrFRd
2026-09-03 00:24:07 -04:00

95 lines
2.5 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import IconButton from "@mui/material/IconButton";
import Tooltip from "@mui/material/Tooltip";
import { useTheme } from "@mui/material/styles";
import { Check, Copy } from "lucide-react";
import { useTranslation } from "react-i18next";
import { useToast } from "@/Hooks/UseToast";
const RESET_DELAY_MS = 1500;
/**
* `navigator.clipboard` only exists on secure origins, and Checkmate is often
* self-hosted over plain HTTP on a LAN address. Fall back to a hidden textarea
* there rather than leaving the button permanently broken.
*/
const copyText = async (value: string) => {
if (navigator.clipboard?.writeText) {
return navigator.clipboard.writeText(value);
}
const textarea = document.createElement("textarea");
textarea.value = value;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
try {
if (!document.execCommand("copy")) throw new Error("Copy command was rejected");
} finally {
document.body.removeChild(textarea);
}
};
/**
* Copies `value` to the clipboard. Shows a tick for a moment so the click is
* acknowledged without moving the user anywhere.
*/
const CopyButton = ({ value, label }: { value: string; label?: string }) => {
const theme = useTheme();
const { t } = useTranslation();
const { toastError } = useToast();
const [copied, setCopied] = useState(false);
const resetTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
// These rows unmount on pagination, so the tick timer has to be cancelled.
useEffect(() => () => clearTimeout(resetTimer.current), []);
const handleCopy = async (e: React.MouseEvent) => {
// The button usually sits inside a clickable row.
e.stopPropagation();
try {
await copyText(value);
setCopied(true);
clearTimeout(resetTimer.current);
resetTimer.current = setTimeout(() => setCopied(false), RESET_DELAY_MS);
} catch {
toastError(t("common.copyFailed"));
}
};
const title = copied ? t("common.copied") : (label ?? t("common.copy"));
return (
<Tooltip
title={title}
disableInteractive
>
<IconButton
onClick={handleCopy}
aria-label={title}
size="small"
>
{copied ? (
<Check
size={14}
strokeWidth={1.5}
color={theme.palette.success.main}
/>
) : (
<Copy
size={14}
strokeWidth={1.5}
color={theme.palette.text.secondary}
/>
)}
</IconButton>
</Tooltip>
);
};
export default CopyButton;