Compare commits

...

1 Commits

Author SHA1 Message Date
J
fa39a658e3 fix(clipboard): support copy over http:// via execCommand fallback
navigator.clipboard is only exposed in a secure context (https or
localhost). On self-hosted instances served over plain http:// it is
undefined, so every copy / "copy all" / export button silently failed and
left the clipboard empty (GitHub #3781).

Add a shared copyText(text): Promise<boolean> helper in
@multica/ui/lib/clipboard that prefers the async Clipboard API and falls
back to a hidden <textarea> + document.execCommand('copy') for non-secure
contexts. Migrate all direct navigator.clipboard.writeText call sites
(code blocks, agent transcript copy-all, token / webhook / issue-link
copy, etc.) to it, gating success side-effects on the returned boolean,
and remove the now-redundant copyMarkdown wrapper. Secure-context users
keep the native path unchanged.

MUL-3068

Co-authored-by: multica-agent <github@multica.ai>
2026-06-05 14:40:21 +08:00
26 changed files with 217 additions and 79 deletions

View File

@@ -16,6 +16,7 @@ import {
X,
} from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import { Button } from "@multica/ui/components/ui/button";
import {
Dialog,
@@ -194,15 +195,12 @@ export function DaemonPanel({
const handleCopy = useCallback(async () => {
const text = filtered.map((l) => l.raw).join("\n");
try {
await navigator.clipboard.writeText(text);
if (await copyText(text)) {
toast.success(
`Copied ${filtered.length} line${filtered.length === 1 ? "" : "s"}`,
);
} catch (err) {
toast.error("Failed to copy", {
description: err instanceof Error ? err.message : String(err),
});
} else {
toast.error("Failed to copy");
}
}, [filtered]);

View File

@@ -2,6 +2,7 @@
import { useState } from "react";
import { Check, Copy, Terminal } from "lucide-react";
import { copyText } from "@multica/ui/lib/clipboard";
import { useLocale } from "../../i18n";
const INSTALL_CMD =
@@ -62,12 +63,9 @@ function CommandBlock({
const [copied, setCopied] = useState(false);
const onCopy = async () => {
try {
await navigator.clipboard.writeText(cmd);
if (await copyText(cmd)) {
setCopied(true);
setTimeout(() => setCopied(false), 1800);
} catch {
// clipboard may be unavailable (insecure context) — silent no-op
}
};

View File

@@ -0,0 +1,60 @@
/**
* Copy text to the clipboard, with a fallback for insecure contexts (plain http://).
*
* The async Clipboard API (`navigator.clipboard`) is only exposed in a secure
* context — `https://` or `localhost`. On a plain `http://` origin it is
* `undefined`, so `navigator.clipboard.writeText` throws and the copy silently
* fails (the symptom behind self-hosted-over-http bug reports). When the secure
* API is unavailable we fall back to a hidden `<textarea>` + the legacy
* `document.execCommand('copy')`, which works in non-secure contexts.
*
* @returns `true` on success, `false` on failure. Callers should gate their
* success side effects (toast, "copied" check state) on the return value and
* surface an error when it is `false`.
*/
export async function copyText(text: string): Promise<boolean> {
// Preferred path: async Clipboard API (secure contexts only).
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// Permission denied / document not focused / blocked — fall through to
// the legacy path below rather than failing outright.
}
}
// Fallback: hidden textarea + execCommand('copy'). Works over plain http://.
if (typeof document === "undefined") return false;
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
// Keep it visually hidden and out of layout/scroll flow.
textarea.style.position = "fixed";
textarea.style.top = "0";
textarea.style.left = "0";
textarea.style.width = "1px";
textarea.style.height = "1px";
textarea.style.padding = "0";
textarea.style.border = "none";
textarea.style.opacity = "0";
textarea.style.pointerEvents = "none";
// Preserve focus so an open menu/popover that owns the copy button is not
// disturbed by the temporary selection.
const previouslyFocused =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
document.body.appendChild(textarea);
try {
textarea.focus();
textarea.select();
return document.execCommand("copy");
} catch {
return false;
} finally {
document.body.removeChild(textarea);
previouslyFocused?.focus();
}
}

View File

@@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"
import { Button } from "@multica/ui/components/ui/button"
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip"
import { cn } from '@multica/ui/lib/utils'
import { copyText } from '../lib/clipboard'
import {
CODE_LIGATURE_CLASS,
CODE_LIGATURE_DESCENDANT_CLASS,
@@ -134,12 +135,9 @@ export function CodeBlock({
}, [code, resolvedLang])
const handleCopy = React.useCallback(async () => {
try {
await navigator.clipboard.writeText(code)
if (await copyText(code)) {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch (err) {
console.error('Failed to copy code:', err)
}
}, [code])

View File

@@ -18,6 +18,7 @@
"./lib/utils": "./lib/utils.ts",
"./lib/data-table": "./lib/data-table.ts",
"./lib/code-style": "./lib/code-style.ts",
"./lib/clipboard": "./lib/clipboard.ts",
"./i18n-types": "./types/i18next.ts",
"./styles/tokens.css": "./styles/tokens.css",
"./styles/base.css": "./styles/base.css"

View File

@@ -29,6 +29,7 @@ import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { Button } from "@multica/ui/components/ui/button";
import { Switch } from "@multica/ui/components/ui/switch";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import { toast } from "sonner";
import {
Dialog,
@@ -291,12 +292,11 @@ function TriggerRow({ trigger, autopilotId }: { trigger: AutopilotTrigger; autop
const handleCopy = async () => {
if (!webhookUrl) return;
try {
await navigator.clipboard.writeText(webhookUrl);
if (await copyText(webhookUrl)) {
setCopied(true);
toast.success(t(($) => $.trigger_row.url_copied));
setTimeout(() => setCopied(false), 1500);
} catch {
} else {
toast.error(t(($) => $.trigger_row.url_copy_failed));
}
};

View File

@@ -20,6 +20,7 @@ import {
Zap,
} from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import {
Dialog,
DialogContent,
@@ -1114,12 +1115,11 @@ function WebhookCreatedPanel({
const handleCopy = async () => {
if (!url) return;
try {
await navigator.clipboard.writeText(url);
if (await copyText(url)) {
setCopied(true);
toast.success(t(($) => $.trigger_row.url_copied));
setTimeout(() => setCopied(false), 1500);
} catch {
} else {
toast.error(t(($) => $.trigger_row.url_copy_failed));
}
};

View File

@@ -29,6 +29,7 @@ import {
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import { toast } from "sonner";
import { useT } from "../../i18n";
import type {
@@ -438,12 +439,11 @@ function CodeBlock({ label, value }: { label: string; value: string }) {
const display = isTruncated ? value.slice(0, TRUNCATE_AT) : value;
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(value);
if (await copyText(value)) {
setCopied(true);
toast.success(t(($) => $.webhook_payload.copied));
setTimeout(() => setCopied(false), 1500);
} catch {
} else {
toast.error(t(($) => $.webhook_payload.copy_failed));
}
};

View File

@@ -4,6 +4,7 @@ import { useState, useMemo } from "react";
import { Webhook, ChevronDown, ChevronRight, Copy, Check } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import { useT } from "../../i18n";
interface WebhookPayloadPreviewProps {
@@ -66,12 +67,11 @@ export function WebhookPayloadPreview({
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
await navigator.clipboard.writeText(fullJSON);
if (await copyText(fullJSON)) {
setCopied(true);
toast.success(t(($) => $.webhook_payload.copied));
setTimeout(() => setCopied(false), 1500);
} catch {
} else {
toast.error(t(($) => $.webhook_payload.copy_failed));
}
};

View File

@@ -21,7 +21,7 @@ import { ChevronRight, ChevronDown, Brain, AlertCircle, AlertTriangle, Copy } fr
import { useScrollFade } from "@multica/ui/hooks/use-scroll-fade";
import { isTaskMessageTaskId, taskMessagesOptions } from "@multica/core/chat/queries";
import { Markdown } from "@multica/views/common/markdown";
import { copyMarkdown } from "../../editor";
import { copyText } from "@multica/ui/lib/clipboard";
import { AttachmentList } from "../../issues/components/comment-card";
import type { AgentAvailability } from "@multica/core/agents";
import type { ChatMessage, ChatPendingTask, TaskFailureReason } from "@multica/core/types";
@@ -305,10 +305,9 @@ function MessageCopyButton({
}) {
const { t } = useT("chat");
const handleCopy = async () => {
try {
await copyMarkdown(extractCopyText(message, timeline));
if (await copyText(extractCopyText(message, timeline))) {
toast.success(t(($) => $.message_list.copied_toast));
} catch {
} else {
toast.error(t(($) => $.message_list.copy_failed_toast));
}
};

View File

@@ -22,6 +22,7 @@ import {
ArrowUpNarrowWide,
} from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import { Dialog, DialogContent, DialogTitle } from "@multica/ui/components/ui/dialog";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@multica/ui/components/ui/collapsible";
import {
@@ -280,7 +281,8 @@ export function AgentTranscriptDialog({
// sequence they see on screen — matters when sort is set to newest-first.
const handleCopyWorkdir = useCallback(() => {
if (!task.relative_work_dir) return;
navigator.clipboard.writeText(task.relative_work_dir).then(() => {
void copyText(task.relative_work_dir).then((ok) => {
if (!ok) return;
setCopiedWorkdir(true);
setTimeout(() => setCopiedWorkdir(false), 2000);
});
@@ -294,7 +296,8 @@ export function AgentTranscriptDialog({
return `[${label}] ${summary}`;
})
.join("\n");
navigator.clipboard.writeText(text).then(() => {
void copyText(text).then((ok) => {
if (!ok) return;
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});

View File

@@ -31,6 +31,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import type { Attachment as AttachmentRecord } from "@multica/core/types";
import { useT } from "../i18n";
import { useAttachmentDownloadResolver } from "./attachment-download-context";
@@ -250,10 +251,9 @@ function ImageAttachmentView({
const { t } = useT("editor");
const handleCopyLink = async () => {
try {
await navigator.clipboard.writeText(src);
if (await copyText(src)) {
toast.success(t(($) => $.image.link_copied));
} catch {
} else {
toast.error(t(($) => $.image.copy_link_failed));
}
};

View File

@@ -5,6 +5,7 @@ import { NodeViewWrapper, NodeViewContent } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { Code as CodeIcon, Copy, Check, Eye } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import { useT } from "../../i18n";
import { MermaidDiagram } from "../mermaid-diagram";
import { CodeBlockIframe } from "../code-block-iframe";
@@ -53,9 +54,10 @@ function CodeBlockView({ node }: NodeViewProps) {
const handleCopy = async () => {
const text = node.textContent;
if (!text) return;
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
if (await copyText(text)) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const showHtmlPreview = isHtml && view === "preview";

View File

@@ -25,6 +25,7 @@ import {
Maximize2,
} from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import {
Dialog,
DialogContent,
@@ -52,13 +53,9 @@ export function HtmlBlockPreview({ html, className }: HtmlBlockPreviewProps) {
const handleCopy = async () => {
if (!html) return;
try {
await navigator.clipboard.writeText(html);
if (await copyText(html)) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
// Clipboard failures are user-recoverable (click again, or copy
// manually from the source view) — no need for a toast here.
}
};

View File

@@ -8,7 +8,6 @@ export {
type TitleEditorProps,
type TitleEditorRef,
} from "./title-editor";
export { copyMarkdown } from "./utils/clipboard";
export { ReadonlyContent } from "./readonly-content";
export { useFileDropZone } from "./use-file-drop-zone";
export { FileDropOverlay } from "./file-drop-overlay";

View File

@@ -15,6 +15,7 @@ import { computePosition, offset, flip, shift } from "@floating-ui/dom";
import { ExternalLink, Copy } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@multica/ui/components/ui/button";
import { copyText } from "@multica/ui/lib/clipboard";
import { useWorkspaceSlug } from "@multica/core/paths";
import { useT } from "../i18n";
import { openLink, isMentionHref } from "./utils/link-handler";
@@ -170,10 +171,9 @@ function LinkHoverCard({
const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
try {
await navigator.clipboard.writeText(href);
if (await copyText(href)) {
toast.success(t(($) => $.link_hover.link_copied));
} catch {
} else {
toast.error(t(($) => $.link_hover.copy_failed));
}
};

View File

@@ -0,0 +1,73 @@
import { afterEach, describe, it, expect, vi } from "vitest";
import { copyText } from "@multica/ui/lib/clipboard";
// jsdom implements neither navigator.clipboard nor document.execCommand, so we
// define them per test to simulate secure (https/localhost) vs insecure (plain
// http://) contexts.
function setClipboard(value: unknown): void {
Object.defineProperty(navigator, "clipboard", {
value,
configurable: true,
writable: true,
});
}
function setExecCommand(result: boolean): ReturnType<typeof vi.fn> {
const mock = vi.fn().mockReturnValue(result);
Object.defineProperty(document, "execCommand", {
value: mock,
configurable: true,
writable: true,
});
return mock;
}
afterEach(() => {
setClipboard(undefined);
vi.restoreAllMocks();
});
describe("copyText", () => {
it("uses the async Clipboard API in a secure context and returns true", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
setClipboard({ writeText });
const ok = await copyText("hello");
expect(ok).toBe(true);
expect(writeText).toHaveBeenCalledWith("hello");
});
it("falls back to execCommand when navigator.clipboard is unavailable (http://)", async () => {
setClipboard(undefined);
const execCommand = setExecCommand(true);
const ok = await copyText("from-http");
expect(ok).toBe(true);
expect(execCommand).toHaveBeenCalledWith("copy");
// The temporary textarea must be cleaned up afterwards.
expect(document.querySelector("textarea")).toBeNull();
});
it("falls back to execCommand when writeText rejects", async () => {
const writeText = vi.fn().mockRejectedValue(new Error("blocked"));
setClipboard({ writeText });
const execCommand = setExecCommand(true);
const ok = await copyText("retry");
expect(ok).toBe(true);
expect(writeText).toHaveBeenCalledWith("retry");
expect(execCommand).toHaveBeenCalledWith("copy");
});
it("returns false when the fallback execCommand fails", async () => {
setClipboard(undefined);
setExecCommand(false);
const ok = await copyText("nope");
expect(ok).toBe(false);
});
});

View File

@@ -1,6 +0,0 @@
/**
* Copy markdown content to the clipboard.
*/
export async function copyMarkdown(markdown: string): Promise<void> {
await navigator.clipboard.writeText(markdown);
}

View File

@@ -42,6 +42,7 @@ import {
ContextMenuSubContent,
ContextMenuSeparator,
} from "@multica/ui/components/ui/context-menu";
import { copyText } from "@multica/ui/lib/clipboard";
import type { UseIssueActionsResult } from "./use-issue-actions";
import { useT } from "../../i18n";
@@ -132,10 +133,10 @@ export function IssueActionsMenuItems({
toast.error(t(($) => $.detail.workdir_path_unavailable));
return;
}
navigator.clipboard.writeText(latestWorkDir).then(
() => toast.success(t(($) => $.detail.workdir_path_copied)),
() => toast.error(t(($) => $.detail.workdir_path_copy_failed)),
);
void copyText(latestWorkDir).then((ok) => {
if (ok) toast.success(t(($) => $.detail.workdir_path_copied));
else toast.error(t(($) => $.detail.workdir_path_copy_failed));
});
}, [tasks, t]);
return (

View File

@@ -10,6 +10,7 @@ import { useWorkspacePaths } from "@multica/core/paths";
import { useModalStore } from "@multica/core/modals";
import { useUpdateIssue } from "@multica/core/issues/mutations";
import { pinListOptions, useCreatePin, useDeletePin } from "@multica/core/pins";
import { copyText } from "@multica/ui/lib/clipboard";
import { useNavigation } from "../../navigation";
import { useT } from "../../i18n";
@@ -101,10 +102,9 @@ export function useIssueActions(issue: Issue | null): UseIssueActionsResult {
const copyLink = useCallback(async () => {
if (!issueId) return;
const url = navigation.getShareableUrl(paths.issueDetail(issueId));
try {
await navigator.clipboard.writeText(url);
if (await copyText(url)) {
toast.success(t(($) => $.detail.link_copied));
} catch {
} else {
toast.error(t(($) => $.detail.link_copy_failed));
}
}, [paths, issueId, navigation, t]);

View File

@@ -28,9 +28,10 @@ import { ActorAvatar } from "../../common/actor-avatar";
import { ReactionBar } from "@multica/ui/components/common/reaction-bar";
import { QuickEmojiPicker } from "@multica/ui/components/common/quick-emoji-picker";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import { useActorName } from "@multica/core/workspace/hooks";
import { useTimeAgo } from "../../i18n";
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent, useFileDropZone, FileDropOverlay, Attachment as AttachmentRenderer, AttachmentDownloadProvider } from "../../editor";
import { ContentEditor, type ContentEditorRef, ReadonlyContent, useFileDropZone, FileDropOverlay, Attachment as AttachmentRenderer, AttachmentDownloadProvider } from "../../editor";
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
@@ -381,8 +382,9 @@ function CommentRow({
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => {
copyMarkdown(entry.content ?? "");
toast.success(t(($) => $.comment.copied_toast));
void copyText(entry.content ?? "").then((ok) => {
if (ok) toast.success(t(($) => $.comment.copied_toast));
});
}}>
<Copy className="h-3.5 w-3.5" />
{t(($) => $.comment.copy_action)}
@@ -595,8 +597,9 @@ function CommentCardImpl({
/>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => {
copyMarkdown(entry.content ?? "");
toast.success(t(($) => $.comment.copied_toast));
void copyText(entry.content ?? "").then((ok) => {
if (ok) toast.success(t(($) => $.comment.copied_toast));
});
}}>
<Copy className="h-3.5 w-3.5" />
{t(($) => $.comment.copy_action)}

View File

@@ -5,6 +5,7 @@ import { Check, Copy, Terminal } from "lucide-react";
import { Card, CardContent } from "@multica/ui/components/ui/card";
import { CODE_LIGATURE_CLASS } from "@multica/ui/lib/code-style";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import { useT } from "../../i18n";
const INSTALL_CMD =
@@ -16,9 +17,11 @@ function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
void copyText(text).then((ok) => {
if (!ok) return;
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
};
return (

View File

@@ -5,6 +5,7 @@ import { useDefaultLayout, usePanelRef } from "react-resizable-panels";
import { Check, ChevronRight, Link2, ListTodo, MoreHorizontal, PanelRight, Pin, PinOff, Plus, Trash2, UserMinus } from "lucide-react";
import { useQuery, type QueryKey } from "@tanstack/react-query";
import { cn } from "@multica/ui/lib/utils";
import { copyText } from "@multica/ui/lib/clipboard";
import { toast } from "sonner";
import type { Issue, IssueAssigneeGroup, ProjectStatus, ProjectPriority, UpdateIssueRequest } from "@multica/core/types";
import { useAuthStore } from "@multica/core/auth";
@@ -754,8 +755,9 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
/>
<DropdownMenuContent align="end" className="w-auto">
<DropdownMenuItem onClick={() => {
navigator.clipboard.writeText(window.location.href);
toast.success(t(($) => $.detail.toast_link_copied));
void copyText(window.location.href).then((ok) => {
if (ok) toast.success(t(($) => $.detail.toast_link_copied));
});
}}>
<Link2 className="h-3.5 w-3.5" />
{t(($) => $.detail.copy_link)}

View File

@@ -18,6 +18,7 @@ import {
} from "@multica/ui/components/ui/dialog";
import { Button } from "@multica/ui/components/ui/button";
import { CODE_LIGATURE_CLASS } from "@multica/ui/lib/code-style";
import { copyText } from "@multica/ui/lib/clipboard";
import { cn } from "@multica/ui/lib/utils";
import { useNavigation } from "../../navigation";
import { useT } from "../../i18n";
@@ -127,8 +128,9 @@ function CopyButton({ text, ariaLabel }: { text: string; ariaLabel: string }) {
}, [copied]);
const handleCopy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
void copyText(text).then((ok) => {
if (ok) setCopied(true);
});
};
return (

View File

@@ -58,6 +58,7 @@ import {
DialogDescription,
} from "@multica/ui/components/ui/dialog";
import { useTheme } from "@multica/ui/components/common/theme-provider";
import { copyText } from "@multica/ui/lib/clipboard";
import { useNavigation } from "../navigation";
import { useT } from "../i18n";
import { matchesPinyin } from "../editor/extensions/pinyin-match";
@@ -221,8 +222,9 @@ export function SearchCommand() {
icon: Link2,
keywords: ["copy", "link", "share", "url", identifier.toLowerCase()],
onSelect: () => {
void navigator.clipboard.writeText(getShareableUrl(pathname));
toast.success(t(($) => $.toast.link_copied));
void copyText(getShareableUrl(pathname)).then((ok) => {
if (ok) toast.success(t(($) => $.toast.link_copied));
});
setOpen(false);
},
},
@@ -232,8 +234,9 @@ export function SearchCommand() {
icon: Copy,
keywords: ["copy", "id", "identifier", identifier.toLowerCase()],
onSelect: () => {
void navigator.clipboard.writeText(identifier);
toast.success(t(($) => $.toast.copied_identifier, { identifier }));
void copyText(identifier).then((ok) => {
if (ok) toast.success(t(($) => $.toast.copied_identifier, { identifier }));
});
setOpen(false);
},
},

View File

@@ -33,6 +33,7 @@ import {
AlertDialogTitle,
} from "@multica/ui/components/ui/alert-dialog";
import { Skeleton } from "@multica/ui/components/ui/skeleton";
import { copyText } from "@multica/ui/lib/clipboard";
import { toast } from "sonner";
import { api } from "@multica/core/api";
import { useT } from "../../i18n";
@@ -95,9 +96,10 @@ export function TokensTab() {
const handleCopyToken = async () => {
if (!newToken) return;
await navigator.clipboard.writeText(newToken);
setTokenCopied(true);
setTimeout(() => setTokenCopied(false), 2000);
if (await copyText(newToken)) {
setTokenCopied(true);
setTimeout(() => setTokenCopied(false), 2000);
}
};
return (