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 <tukon479@gmail.com>
This commit is contained in:
Innei 2025-06-16 17:55:15 +08:00
parent 1ace5eaaa3
commit 394d00f1a2
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
17 changed files with 378 additions and 22 deletions

View File

@ -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"))

View File

@ -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<any>(undefined)
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(value)
copyToClipboard(value)
clearTimeout(copiedTimerRef.current)
}, [value])

View File

@ -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}`)
},
}),
]

View File

@ -0,0 +1,24 @@
import { toast } from "sonner"
export const copyToClipboard = async (content: string): Promise<void> => {
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<string> => {
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)
}
}

View File

@ -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<string> => {
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()
})
}

View File

@ -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 (
<div className="flex w-full items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">
<i className="i-mgc-share-forward-cute-re mr-2" />
Share
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleExport}>
<i className="i-mgc-download-2-cute-re mr-2" />
Export to File
</DropdownMenuItem>
<DropdownMenuItem onClick={handleImport}>
<i className="i-mgc-file-upload-cute-re mr-2" />
Import from File
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleCopyToClipboard}>
<i className="i-mgc-copy-2-cute-re mr-2" />
Copy to Clipboard
</DropdownMenuItem>
<DropdownMenuItem onClick={handleImportFromClipboard}>
<i className="i-mgc-paste-cute-re mr-2" />
Import from Clipboard
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
variant={actionLength === 0 ? "primary" : "outline"}
onClick={() => actionActions.addRule((number) => t("actions.actionName", { number }))}

View File

@ -0,0 +1,6 @@
export const generateExportFilename = () => {
const now = new Date()
const dateStr = now.toISOString().split("T")[0] // YYYY-MM-DD
const timeStr = now.toTimeString().split(" ")[0]?.replaceAll(":", "-") // HH-MM-SS
return `follow-actions-${dateStr}-${timeStr}.json`
}

View File

@ -28,6 +28,7 @@ import { toggleEntryReadability } from "~/hooks/biz/useEntryActions"
import { navigateEntry } from "~/hooks/biz/useNavigateEntry"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { ipcServices } from "~/lib/client"
import { copyToClipboard } from "~/lib/clipboard"
import { parseHtml } from "~/lib/parse-html"
import { useActivationModal } from "~/modules/activation"
import { markAllByRoute } from "~/modules/entry-column/hooks/useMarkAll"
@ -186,7 +187,7 @@ export const useRegisterEntryCommands = () => {
return
}
if (!entry.url) return
navigator.clipboard.writeText(entry.url)
copyToClipboard(entry.url)
toast(t("entry_actions.copied_notify", { which: t("words.link") }), {
duration: 1000,
})
@ -220,7 +221,7 @@ export const useRegisterEntryCommands = () => {
return
}
if (!entry.title) return
navigator.clipboard.writeText(entry.title)
copyToClipboard(entry.title)
toast(t("entry_actions.copied_notify", { which: t("words.title") }), {
duration: 1000,
})

View File

@ -6,6 +6,7 @@ import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { useDeleteSubscription } from "~/hooks/biz/useSubscriptionActions"
import { copyToClipboard } from "~/lib/clipboard"
import { UrlBuilder } from "~/lib/url-builder"
import { ListForm } from "~/modules/discover/ListForm"
@ -68,7 +69,7 @@ export const useRegisterListCommands = () => {
run: async ({ listId }) => {
if (!listId) return
const { view } = getRouteParams()
await navigator.clipboard.writeText(UrlBuilder.shareList(listId, view))
await copyToClipboard(UrlBuilder.shareList(listId, view))
toast.success("copy success!", {
duration: 1000,
})
@ -80,7 +81,7 @@ export const useRegisterListCommands = () => {
category,
run: async ({ listId }) => {
if (!listId) return
await navigator.clipboard.writeText(listId)
await copyToClipboard(listId)
toast.success("copy success!", {
duration: 1000,
})

View File

@ -26,6 +26,7 @@ import { useFeedActions } from "~/hooks/biz/useFeedActions"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { getRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useContextMenu } from "~/hooks/common/useContextMenu"
import { copyToClipboard } from "~/lib/clipboard"
import { COMMAND_ID } from "~/modules/command/commands/id"
export const EntryItemWrapper: FC<
@ -157,7 +158,7 @@ export const EntryItemWrapper: FC<
new MenuItemText({
label: `${t("words.copy")}${t("space")}${t("words.entry")} ${t("words.id")}`,
click: () => {
navigator.clipboard.writeText(entry?.id || "")
copyToClipboard(entry?.id || "")
},
}),
],

View File

@ -26,6 +26,7 @@ import { CopyButton } from "~/components/ui/button/CopyButton"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useAuthQuery } from "~/hooks/common"
import { apiClient } from "~/lib/api-fetch"
import { copyToClipboard } from "~/lib/clipboard"
import { toastFetchError } from "~/lib/error-parser"
import { usePresentUserProfileModal, useTOTPModalWrapper } from "~/modules/profile/hooks"
import { UserAvatar } from "~/modules/user/UserAvatar"
@ -179,7 +180,7 @@ const ConfirmModalContent = ({ dismiss }: { dismiss: () => void }) => {
onSuccess(data) {
Queries.invitations.list().invalidate()
toast(t("invitation.newInvitationSuccess"))
navigator.clipboard.writeText(data.data)
copyToClipboard(data.data)
dismiss()
},
})

View File

@ -26,6 +26,7 @@ import { useBackHome } from "~/hooks/biz/useNavigateEntry"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useI18n } from "~/hooks/common"
import { useContextMenu } from "~/hooks/common/useContextMenu"
import { copyToClipboard } from "~/lib/clipboard"
import { ProfileButton } from "~/modules/user/ProfileButton"
export const SubscriptionColumnHeader = memo(() => {
@ -133,7 +134,7 @@ const LogoContextMenu: FC<PropsWithChildren> = ({ children }) => {
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(logoRef.current?.outerHTML || "")
copyToClipboard(logoRef.current?.outerHTML || "")
setOpen(false)
toast.success(t.common("app.copied_to_clipboard"))
}}
@ -143,7 +144,7 @@ const LogoContextMenu: FC<PropsWithChildren> = ({ children }) => {
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
navigator.clipboard.writeText(logoTextRef.current?.outerHTML || "")
copyToClipboard(logoTextRef.current?.outerHTML || "")
setOpen(false)
toast.success(t.common("app.copied_to_clipboard"))
}}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"><path d="M7.38 1.576c-.449.112-.967.431-1.257.775-.145.173-.183.195-.422.24-1.331.252-2.539 1.305-2.973 2.589-.211.627-.208.544-.208 4.948.001 2.422.017 4.288.041 4.572.1 1.173.331 1.906.839 2.663.222.331.906 1.015 1.237 1.237.983.66 1.978.88 3.987.88h.772l.171.329c.226.435.577.903.954 1.272.745.73 1.775 1.232 2.779 1.355.577.07 3.497.058 4.005-.018 1.817-.269 3.367-1.6 3.952-3.392.2-.612.223-.954.223-3.251 0-2.242-.021-2.598-.185-3.175a5.927 5.927 0 0 0-.704-1.469 5.287 5.287 0 0 0-1.78-1.563l-.326-.169-.015-1.81c-.017-1.951-.014-1.914-.252-2.55-.454-1.21-1.604-2.18-2.898-2.445-.242-.05-.279-.073-.541-.334a2.562 2.562 0 0 0-.962-.63l-.237-.09-3-.007c-2.288-.006-3.047.004-3.2.043m5.871 1.995a.501.501 0 0 1 0 .858c-.099.066-.299.072-2.609.083-1.376.007-2.578.001-2.672-.012-.554-.08-.633-.818-.105-.979.063-.019 1.277-.032 2.697-.028 2.392.006 2.589.012 2.689.078M5.695 4.97c.145.34.454.748.734.97.278.221.713.436 1.003.496.298.062 5.844.061 6.136-.001.708-.15 1.358-.683 1.7-1.395.079-.165.149-.306.155-.314.025-.031.368.236.54.42.222.237.367.481.46.774.061.188.073.427.087 1.645l.017 1.426-1.574.021c-1.683.023-1.908.045-2.531.249-.732.238-1.352.628-1.943 1.218-.59.591-.98 1.211-1.218 1.943-.217.663-.228.802-.249 3.018l-.02 2.06-.886-.026c-.88-.026-1.392-.089-1.747-.214-.75-.264-1.479-1.057-1.675-1.823-.166-.652-.18-1.06-.182-5.257-.002-3.764.002-4.014.071-4.24a1.95 1.95 0 0 1 .443-.774c.152-.166.493-.443.548-.445.013-.001.072.112.131.249m11.565 6.125a3.093 3.093 0 0 1 2.118 2.065c.126.415.18 2.616.102 4.22-.03.644-.051.804-.136 1.05-.323.941-.972 1.59-1.914 1.915-.257.088-.398.104-1.23.142-1.153.052-2.694-.003-3.04-.109a3.013 3.013 0 0 1-1.842-1.558c-.289-.588-.288-.579-.308-2.707-.02-2.207.006-2.656.187-3.153a2.97 2.97 0 0 1 1.766-1.764c.469-.171.757-.191 2.517-.178 1.278.01 1.581.023 1.78.077" fill="#10161F" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -49,9 +49,9 @@ function RadioGroupIndicator({ className, transition, ...props }: RadioGroupIndi
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-3 fill-current text-current"
>
<circle cx="12" cy="12" r="10" />

View File

@ -4,7 +4,7 @@ import { FetchError } from "ofetch"
import { useCallback } from "react"
import type { GeneralMutationOptions } from "../types"
import { actionSyncService, useActionStore } from "./store"
import { actionActions, actionSyncService, useActionStore } from "./store"
export const usePrefetchActions = () => {
return useQuery({
@ -72,3 +72,10 @@ export const useHasNotificationActions = () => {
return state.rules.some((rule) => !!rule.result.newEntryNotification && !rule.result.disabled)
})
}
export const useActionImportExport = () => {
return {
exportRules: () => actionActions.exportRules(),
importRules: (jsonData: string) => actionActions.importRules(jsonData),
}
}

View File

@ -256,6 +256,68 @@ class ActionActions {
state.isDirty = true
})
}
exportRules(): string {
const { rules } = useActionStore.getState()
const exportData = {
version: "1.0",
exportDate: new Date().toISOString(),
rules: rules.map((rule) => ({
name: rule.name,
condition: rule.condition,
result: rule.result,
})),
}
return JSON.stringify(exportData)
}
importRules(jsonData: string): { success: boolean; message: string; importedCount?: number } {
try {
const parsedData = JSON.parse(jsonData)
// Validate the structure
if (!parsedData.rules || !Array.isArray(parsedData.rules)) {
return { success: false, message: "Invalid JSON structure: missing or invalid rules array" }
}
// Validate each rule structure
for (const rule of parsedData.rules) {
if (!rule.name || typeof rule.name !== "string") {
return { success: false, message: "Invalid rule: missing or invalid name field" }
}
if (!rule.condition || !Array.isArray(rule.condition)) {
return { success: false, message: "Invalid rule: missing or invalid condition field" }
}
if (!rule.result || typeof rule.result !== "object") {
return { success: false, message: "Invalid rule: missing or invalid result field" }
}
}
// Import the rules
const importedRules: ActionRules = parsedData.rules.map((rule: any, index: number) => ({
name: rule.name,
condition: rule.condition,
result: rule.result,
index,
}))
immerSet((state) => {
state.rules = importedRules
state.isDirty = true
})
return {
success: true,
message: `Successfully imported ${importedRules.length} action rule(s)`,
importedCount: importedRules.length,
}
} catch (error) {
return {
success: false,
message: `Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`,
}
}
}
}
export const actionSyncService = new ActionSyncService()

View File

@ -0,0 +1,102 @@
export class JsonObfuscatedCodec {
// Custom charset for converting bytes to printable characters
private static charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
private static charsetLength = JsonObfuscatedCodec.charset.length
// Generate a key
private static key = "Folo"
// Convert string to byte array
private static toBytes(str: string): Uint8Array {
const encoder = new TextEncoder()
return encoder.encode(str)
}
// Convert byte array to string
private static fromBytes(bytes: Uint8Array): string {
const decoder = new TextDecoder()
return decoder.decode(bytes)
}
// XOR encrypt/decrypt byte array
private static xorBytes(input: Uint8Array, key: string): Uint8Array {
const keyBytes = this.toBytes(key)
const output = new Uint8Array(input.length)
for (const [i, element] of input.entries()) {
const keyByte = keyBytes[i % keyBytes.length]
if (keyByte !== undefined) {
output[i] = element ^ keyByte
}
}
return output
}
// Convert byte array to custom charset string
private static bytesToCharset(bytes: Uint8Array): string {
let result = ""
for (const byte of bytes) {
// Map each byte to two characters (add confusion effect)
const high = Math.floor(byte / this.charsetLength)
const low = byte % this.charsetLength
const highChar = this.charset[high]
const lowChar = this.charset[low]
if (highChar !== undefined && lowChar !== undefined) {
result += highChar + lowChar
}
}
return result
}
// Convert custom charset string to byte array
private static charsetToBytes(str: string): Uint8Array {
const bytes = new Uint8Array(str.length / 2)
for (let i = 0; i < str.length; i += 2) {
const highChar = str[i]
const lowChar = str[i + 1]
if (highChar === undefined || lowChar === undefined) {
throw new Error("Invalid encoded string: incomplete character pair")
}
const high = this.charset.indexOf(highChar)
const low = this.charset.indexOf(lowChar)
if (high === -1 || low === -1) {
throw new Error("Invalid encoded string")
}
bytes[i / 2] = high * this.charsetLength + low
}
return bytes
}
// Encode JSON object to obfuscated string
static encode(obj: any, key: string = this.key): string {
try {
// Convert JSON object to string (support Chinese)
const jsonStr = JSON.stringify(obj)
// Convert to byte array
const bytes = this.toBytes(jsonStr)
// Use XOR encryption
const encrypted = this.xorBytes(bytes, key)
// Convert to custom charset string
return this.bytesToCharset(encrypted)
} catch (error) {
console.error("Encoding error:", error)
throw new Error("Failed to encode JSON")
}
}
// Decode obfuscated string to JSON object
static decode(encodedStr: string, key: string = this.key): any {
try {
// Convert from custom charset string to byte array
const bytes = this.charsetToBytes(encodedStr)
// Use XOR decryption
const decrypted = this.xorBytes(bytes, key)
// Convert to JSON string
const jsonStr = this.fromBytes(decrypted)
// Parse JSON
return JSON.parse(jsonStr)
} catch (error) {
console.error("Decoding error:", error)
throw new Error("Failed to decode JSON")
}
}
}