From 394d00f1a2bf734ced3f71c85c8d3a471e2b6d71 Mon Sep 17 00:00:00 2001 From: Innei Date: Mon, 16 Jun 2025 17:55:15 +0800 Subject: [PATCH] feat: support share actions and import from clipboard - Added a new utility function `copyToClipboard` to handle clipboard operations with error handling. - Refactored existing clipboard interactions across various components and hooks to utilize the new utility function. - Introduced import/export functionality for action rules, allowing users to copy rules to clipboard and import from clipboard. - Enhanced user feedback with toast notifications for clipboard actions. This update improves code reusability and user experience when interacting with the clipboard. Signed-off-by: Innei --- .../src/components/common/SharePanel.tsx | 7 +- .../src/components/ui/button/CopyButton.tsx | 3 +- .../renderer/src/hooks/biz/useFeedActions.tsx | 13 +- .../layer/renderer/src/lib/clipboard.ts | 24 ++++ apps/desktop/layer/renderer/src/lib/export.ts | 34 ++++++ .../src/modules/action/action-setting.tsx | 112 ++++++++++++++++++ .../renderer/src/modules/action/utils.ts | 6 + .../src/modules/command/commands/entry.tsx | 5 +- .../src/modules/command/commands/list.tsx | 5 +- .../entry-column/layouts/EntryItemWrapper.tsx | 3 +- .../src/modules/settings/tabs/invitations.tsx | 3 +- .../SubscriptionColumnHeader.tsx | 5 +- icons/mgc/paste_cute_re.svg | 1 + .../components/src/ui/radio-group/motion.tsx | 6 +- packages/internal/store/src/action/hooks.ts | 9 +- packages/internal/store/src/action/store.ts | 62 ++++++++++ packages/internal/utils/src/json-codec.ts | 102 ++++++++++++++++ 17 files changed, 378 insertions(+), 22 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/lib/clipboard.ts create mode 100644 apps/desktop/layer/renderer/src/lib/export.ts create mode 100644 apps/desktop/layer/renderer/src/modules/action/utils.ts create mode 100644 icons/mgc/paste_cute_re.svg create mode 100644 packages/internal/utils/src/json-codec.ts diff --git a/apps/desktop/layer/renderer/src/components/common/SharePanel.tsx b/apps/desktop/layer/renderer/src/components/common/SharePanel.tsx index dad0b116f..700a71079 100644 --- a/apps/desktop/layer/renderer/src/components/common/SharePanel.tsx +++ b/apps/desktop/layer/renderer/src/components/common/SharePanel.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next" import { toast } from "sonner" import { ipcServices } from "~/lib/client" +import { copyToClipboard } from "~/lib/clipboard" interface SharePanelProps { entryId: string @@ -136,13 +137,13 @@ export const SharePanel = ({ entryId }: SharePanelProps) => { }) } else { // Fallback to copying link - await navigator.clipboard.writeText(shareContent.url) + await copyToClipboard(shareContent.url) toast.success(t("share.link_copied")) } } catch { // If sharing fails, copy link as fallback try { - await navigator.clipboard.writeText(shareContent.url) + await copyToClipboard(shareContent.url) toast.success(t("share.link_copied")) } catch { toast.error(t("share.copy_failed")) @@ -153,7 +154,7 @@ export const SharePanel = ({ entryId }: SharePanelProps) => { const handleCopyLink = useCallback(async () => { const shareUrl = getShareUrl(entryId) try { - await navigator.clipboard.writeText(shareUrl) + await copyToClipboard(shareUrl) toast.success(t("share.link_copied")) } catch { toast.error(t("share.copy_failed")) diff --git a/apps/desktop/layer/renderer/src/components/ui/button/CopyButton.tsx b/apps/desktop/layer/renderer/src/components/ui/button/CopyButton.tsx index 434be42e7..9456aee6a 100644 --- a/apps/desktop/layer/renderer/src/components/ui/button/CopyButton.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/button/CopyButton.tsx @@ -1,6 +1,7 @@ import { useCallback, useRef } from "react" import { m } from "~/components/common/Motion" +import { copyToClipboard } from "~/lib/clipboard" import { AnimatedCommandButton } from "./base" @@ -11,7 +12,7 @@ export const CopyButton: Component<{ }> = ({ value, className, style, variant = "solid" }) => { const copiedTimerRef = useRef(undefined) const handleCopy = useCallback(() => { - navigator.clipboard.writeText(value) + copyToClipboard(value) clearTimeout(copiedTimerRef.current) }, [value]) diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useFeedActions.tsx b/apps/desktop/layer/renderer/src/hooks/biz/useFeedActions.tsx index 191c69b9b..78bd8e856 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useFeedActions.tsx +++ b/apps/desktop/layer/renderer/src/hooks/biz/useFeedActions.tsx @@ -23,6 +23,7 @@ import { MenuItemSeparator, MenuItemText } from "~/atoms/context-menu" import { useIsInMASReview } from "~/atoms/server-configs" import { whoami } from "~/atoms/user" import { useModalStack } from "~/components/ui/modal/stacked/hooks" +import { copyToClipboard } from "~/lib/clipboard" import { UrlBuilder } from "~/lib/url-builder" import { useBoostModal } from "~/modules/boost/hooks" import { useFeedClaimModal } from "~/modules/claim" @@ -293,7 +294,7 @@ export const useFeedActions = ({ const { url, siteUrl } = feed || {} const copied = url || siteUrl if (!copied) return - navigator.clipboard.writeText(copied) + copyToClipboard(copied) }, }), new MenuItemText({ @@ -301,14 +302,14 @@ export const useFeedActions = ({ shortcut: "$mod+Shift+C", disabled: isEntryList, click: () => { - navigator.clipboard.writeText(feedId) + copyToClipboard(feedId) }, }), new MenuItemText({ label: t("sidebar.feed_actions.copy_feed_badge"), disabled: isEntryList, click: () => { - navigator.clipboard.writeText( + copyToClipboard( `https://badge.follow.is/feed/${feedId}?color=FF5C00&labelColor=black&style=flat-square`, ) }, @@ -424,14 +425,14 @@ export const useListActions = ({ listId, view }: { listId: string; view?: FeedVi label: t("sidebar.feed_actions.copy_list_url"), shortcut: "$mod+C", click: () => { - navigator.clipboard.writeText(UrlBuilder.shareList(listId, view)) + copyToClipboard(UrlBuilder.shareList(listId, view)) }, }), new MenuItemText({ label: t("sidebar.feed_actions.copy_list_id"), shortcut: "$mod+Shift+C", click: () => { - navigator.clipboard.writeText(listId) + copyToClipboard(listId) }, }), ] @@ -466,7 +467,7 @@ export const useInboxActions = ({ inboxId }: { inboxId: string }) => { label: t("sidebar.feed_actions.copy_email_address"), shortcut: "$mod+Shift+C", click: () => { - navigator.clipboard.writeText(`${inboxId}${env.VITE_INBOXES_EMAIL}`) + copyToClipboard(`${inboxId}${env.VITE_INBOXES_EMAIL}`) }, }), ] diff --git a/apps/desktop/layer/renderer/src/lib/clipboard.ts b/apps/desktop/layer/renderer/src/lib/clipboard.ts new file mode 100644 index 000000000..4807060c2 --- /dev/null +++ b/apps/desktop/layer/renderer/src/lib/clipboard.ts @@ -0,0 +1,24 @@ +import { toast } from "sonner" + +export const copyToClipboard = async (content: string): Promise => { + try { + await navigator.clipboard.writeText(content) + } catch (e) { + const message = "Unable to copy to clipboard. Please ensure clipboard permissions are granted." + console.error(e) + toast.error(message) + throw new Error(message) + } +} + +export const readFromClipboard = async (): Promise => { + try { + return await navigator.clipboard.readText() + } catch (e) { + const message = + "Unable to read from clipboard. Please ensure clipboard permissions are granted." + toast.error(message) + console.error(e) + throw new Error(message) + } +} diff --git a/apps/desktop/layer/renderer/src/lib/export.ts b/apps/desktop/layer/renderer/src/lib/export.ts new file mode 100644 index 000000000..7df39a660 --- /dev/null +++ b/apps/desktop/layer/renderer/src/lib/export.ts @@ -0,0 +1,34 @@ +export const downloadJsonFile = (content: string, filename: string) => { + const blob = new Blob([content], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const link = document.createElement("a") + link.href = url + link.download = filename + document.body.append(link) + link.click() + link.remove() + URL.revokeObjectURL(url) +} + +export const selectJsonFile = (): Promise => { + return new Promise((resolve, reject) => { + const input = document.createElement("input") + input.type = "file" + input.accept = ".json,application/json" + input.onchange = async (event) => { + const file = (event.target as HTMLInputElement).files?.[0] + if (!file) { + reject(new Error("No file selected")) + return + } + + try { + const content = await file.text() + resolve(content) + } catch { + reject(new Error("Failed to read file")) + } + } + input.click() + }) +} diff --git a/apps/desktop/layer/renderer/src/modules/action/action-setting.tsx b/apps/desktop/layer/renderer/src/modules/action/action-setting.tsx index f208fa182..35cff542f 100644 --- a/apps/desktop/layer/renderer/src/modules/action/action-setting.tsx +++ b/apps/desktop/layer/renderer/src/modules/action/action-setting.tsx @@ -7,13 +7,25 @@ import { useUpdateActionsMutation, } from "@follow/store/action/hooks" import { actionActions } from "@follow/store/action/store" +import { JsonObfuscatedCodec } from "@follow/utils/json-codec" import { useQueryClient } from "@tanstack/react-query" import { useTranslation } from "react-i18next" import { unstable_usePrompt } from "react-router" import { toast } from "sonner" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu/dropdown-menu.js" +import { copyToClipboard, readFromClipboard } from "~/lib/clipboard" +import { downloadJsonFile, selectJsonFile } from "~/lib/export" import { RuleCard } from "~/modules/action/rule-card" +import { generateExportFilename } from "./utils" + export const ActionSetting = () => { const actions = useActionRules() @@ -59,8 +71,108 @@ const ActionButtonGroup = () => { }, }) + const handleExport = () => { + try { + const jsonData = actionActions.exportRules() + const filename = generateExportFilename() + downloadJsonFile(jsonData, filename) + toast.success(`Action rules exported successfully as ${filename}`) + } catch { + toast.error("Failed to export action rules") + } + } + + const handleImport = async () => { + try { + const jsonData = await selectJsonFile() + const result = actionActions.importRules(jsonData) + + if (result.success) { + toast.success(result.message) + } else { + toast.error(result.message) + } + } catch (error) { + if (error instanceof Error && error.message === "No file selected") { + // User cancelled file selection, don't show error + return + } + toast.error("Failed to import action rules") + } + } + + const foloPrefix = "folo:actions#" + const handleCopyToClipboard = async () => { + try { + const jsonData = actionActions.exportRules() + const codecData = JsonObfuscatedCodec.encode(jsonData) + + await copyToClipboard(`${foloPrefix}${codecData}`) + toast.success("Action rules copied to clipboard") + } catch (error) { + toast.error("Failed to copy action rules to clipboard") + console.error(error) + } + } + + const handleImportFromClipboard = async () => { + try { + const clipboardData = await readFromClipboard() + if (!clipboardData.startsWith(foloPrefix)) { + toast.error("Invalid clipboard data") + return + } + const codecData = clipboardData.slice(foloPrefix.length) + + const jsonData = JsonObfuscatedCodec.decode(codecData) + + const result = actionActions.importRules(jsonData) + + if (result.success) { + toast.success(result.message) + } else { + toast.error(result.message) + } + } catch (error) { + if (error instanceof Error && error.message.includes("clipboard")) { + toast.error(error.message) + } else { + toast.error("Failed to import from clipboard") + } + console.error(error) + } + } + return (
+ + + + + + + + Export to File + + + + Import from File + + + + + Copy to Clipboard + + + + Import from Clipboard + + + +