diff --git a/apps/desktop/layer/renderer/src/atoms/popover.ts b/apps/desktop/layer/renderer/src/atoms/popover.ts new file mode 100644 index 000000000..01138ee0c --- /dev/null +++ b/apps/desktop/layer/renderer/src/atoms/popover.ts @@ -0,0 +1,43 @@ +import type { PopoverContentProps } from "@radix-ui/react-popover" +import { atom } from "jotai" +import type { ReactNode } from "react" + +import { createAtomHooks, jotaiStore } from "~/lib/jotai" + +// Atom + +export interface PopoverProps extends Omit { + /** Custom z-index for popover */ + zIndex?: number + /** Whether the popover should close when clicked outside */ + modal?: boolean +} + +type PopoverState = + | { open: false } + | { + open: true + position: { x: number; y: number } + content: ReactNode + props?: PopoverProps + // Just for abort callback + abortController: AbortController + } + +export const [popoverAtom, usePopoverState, usePopoverValue, useSetPopover] = createAtomHooks( + atom({ open: false }), +) + +export const showPopover = ( + mouseXY: { x: number; y: number }, + element: ReactNode, + props?: PopoverProps, +) => { + jotaiStore.set(popoverAtom, { + open: true, + position: mouseXY, + content: element, + props, + abortController: new AbortController(), + }) +} diff --git a/apps/desktop/layer/renderer/src/components/common/SharePanel.tsx b/apps/desktop/layer/renderer/src/components/common/SharePanel.tsx new file mode 100644 index 000000000..d237fd575 --- /dev/null +++ b/apps/desktop/layer/renderer/src/components/common/SharePanel.tsx @@ -0,0 +1,264 @@ +import { IN_ELECTRON } from "@follow/shared/constants" +import { cn } from "@follow/utils/utils" +import { useCallback } from "react" +import { useTranslation } from "react-i18next" +import { toast } from "sonner" + +import { ipcServices } from "~/lib/client" +import { getEntry } from "~/store/entry" + +interface SharePanelProps { + entryId: string +} + +interface ShareOption { + id: string + label: string + icon: string + action: () => Promise | void + color?: string + bgColor?: string +} + +interface SocialShareOption { + id: string + label: string + icon: string + url: string + color: string + bgColor: string +} + +const socialOptions: SocialShareOption[] = [ + { + id: "twitter", + label: "X", + icon: tw`i-mgc-social-x-cute-re`, + url: "https://x.com/intent/tweet?text={text}&url={url}", + color: "text-white", + bgColor: "bg-black", + }, + { + id: "facebook", + label: "Facebook", + icon: tw`i-mgc-facebook-cute-re`, + url: "https://www.facebook.com/sharer/sharer.php?u={url}", + color: "text-white", + bgColor: "bg-[#1877F2]", + }, + { + id: "telegram", + label: "Telegram", + icon: tw`i-mgc-telegram-cute-re`, + url: "https://t.me/share/url?url={url}&text={text}", + color: "text-white", + bgColor: "bg-[#0088CC]", + }, + { + id: "weibo", + label: "微博", + icon: tw`i-mgc-weibo-cute-re`, + url: "https://service.weibo.com/share/share.php?url={url}&title={text}", + color: "text-white", + bgColor: "bg-[#E6162D]", + }, +] + +export const SharePanel = ({ entryId }: SharePanelProps) => { + const { t } = useTranslation() + + const generateShareContent = useCallback( + (entry: ReturnType) => { + if (!entry) return null + + const { title, description } = entry.entries + const shareUrl = globalThis.location.href + + // Limit text to 50 characters with ellipsis + const truncateText = (text: string, maxLength = 50) => { + return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text + } + + const shareTitle = `${title || t("share.default_title")} - Follow` + const baseText = description || title || t("share.default_description") + const truncatedText = truncateText(baseText) + const shareText = `${truncatedText} | ${t("share.discover_more")}` + + return { + title: shareTitle, + text: shareText, + url: shareUrl, + } + }, + [t], + ) + + const handleNativeShare = useCallback(async () => { + const entry = getEntry(entryId) + const shareContent = generateShareContent(entry) + + if (!shareContent) return + + try { + if (IN_ELECTRON) { + // Use Electron's share menu + await ipcServices?.menu.showShareMenu(shareContent.url) + } else if (navigator.share) { + // Use Web Share API + await navigator.share({ + title: shareContent.title, + text: shareContent.text, + url: shareContent.url, + }) + } else { + // Fallback to copying link + await navigator.clipboard.writeText(shareContent.url) + toast.success(t("share.link_copied")) + } + } catch { + // If sharing fails, copy link as fallback + try { + await navigator.clipboard.writeText(shareContent.url) + toast.success(t("share.link_copied")) + } catch { + toast.error(t("share.copy_failed")) + } + } + }, [entryId, generateShareContent, t]) + + const handleCopyLink = useCallback(async () => { + const shareUrl = globalThis.location.href + try { + await navigator.clipboard.writeText(shareUrl) + toast.success(t("share.link_copied")) + } catch { + toast.error(t("share.copy_failed")) + } + }, [t]) + + const handleSocialShare = useCallback( + (shareUrlTemplate: string) => { + const entry = getEntry(entryId) + const shareContent = generateShareContent(entry) + + if (!shareContent) return + + const encodedUrl = encodeURIComponent(shareContent.url) + const shareTitle = encodeURIComponent(shareContent.title) + const shareText = encodeURIComponent(shareContent.text) + + const finalUrl = shareUrlTemplate + .replace("{url}", encodedUrl) + .replace("{title}", shareTitle) + .replace("{text}", shareText) + + window.open(finalUrl, "_blank", "width=600,height=400") + }, + [entryId, generateShareContent], + ) + + const actionOptions: ShareOption[] = [ + ...(IN_ELECTRON || (typeof navigator !== "undefined" && "share" in navigator) + ? [ + { + id: "native-share", + label: t("share.system_share"), + icon: "i-mgc-share-forward-cute-re", + action: handleNativeShare, + color: "text-blue-500", + }, + ] + : []), + { + id: "copy-link", + label: t("share.copy_link"), + icon: "i-mgc-link-cute-re", + action: handleCopyLink, + }, + ] + + return ( +
+
+

{t("share.title")}

+ {(() => { + const entry = getEntry(entryId) + const title = entry?.entries?.title + return title ? ( +

+ {title} +

+ ) : null + })()} +
+ +
+
+

+ {t("share.social_media")} +

+
+
+ {socialOptions.map((option) => ( + + ))} +
+
+ +
+
+

+ {t("share.actions")} +

+
+
+ {actionOptions.map((option) => ( + + ))} +
+
+
+ ) +} diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx b/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx index edc73f66d..cf860a009 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx +++ b/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx @@ -328,7 +328,7 @@ export const useEntryActions = ({ new EntryActionMenuItem({ id: COMMAND_ID.entry.share, onClick: runCmdFn(COMMAND_ID.entry.share, [{ entryId }]), - hide: !entry.url || !("share" in navigator || IN_ELECTRON), + hide: !entry.url, shortcut: shortcuts[COMMAND_ID.entry.share], entryId, }), diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/entry.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/entry.tsx index ed9dc8431..6598639a5 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/entry.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/entry.tsx @@ -1,3 +1,4 @@ +import { getMousePosition } from "@follow/components/hooks/useMouse.js" import { FeedViewType, UserRole } from "@follow/constants" import { IN_ELECTRON } from "@follow/shared/constants" import { cn, resolveUrlWithBase } from "@follow/utils/utils" @@ -8,6 +9,7 @@ import { toast } from "sonner" import { toggleShowAISummaryOnce } from "~/atoms/ai-summary" import { toggleShowAITranslationOnce } from "~/atoms/ai-translation" import { AudioPlayer, getAudioPlayerAtomValue } from "~/atoms/player" +import { showPopover } from "~/atoms/popover" import { useIsInMASReview } from "~/atoms/server-configs" import { useGeneralSettingKey } from "~/atoms/settings/general" import { @@ -16,6 +18,7 @@ import { useSourceContentModal, } from "~/atoms/source-content" import { useUserRole } from "~/atoms/user" +import { SharePanel } from "~/components/common/SharePanel" import { toggleEntryReadability } from "~/hooks/biz/useEntryActions" import { navigateEntry } from "~/hooks/biz/useNavigateEntry" import { getRouteParams } from "~/hooks/biz/useRouteParams" @@ -283,19 +286,15 @@ export const useRegisterEntryCommands = () => { toast.error("Failed to share: url is not available", { duration: 3000 }) return } - if (!entry.entries.url) return - if (IN_ELECTRON) { - return ipcServices?.menu.showShareMenu(entry.entries.url) - } else { - const { title, description } = entry.entries - navigator.share({ - title: title || undefined, - text: description || undefined, - url: entry.entries.url, - }) - } - return + const xy = getMousePosition() + showPopover( + { + x: xy.x, + y: xy.y + 20, + }, + , + ) }, }, { diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/index.shared.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/index.shared.tsx index 4591eaab9..75c7b7cd3 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/index.shared.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/index.shared.tsx @@ -256,7 +256,7 @@ export const ContainerToc = memo( className={cn( "animate-in fade-in-0 slide-in-from-bottom-12 easing-spring spring-soft flex flex-col items-end", "scrollbar-none max-h-[calc(100vh-100px)] overflow-auto", - "@[700px]:-translate-x-12 @[800px]:-translate-x-16 @[900px]:translate-x-0 @[900px]:items-start", + "@[700px]:-translate-x-12 @[800px]:-translate-x-4 @[900px]:translate-x-0 @[900px]:items-start", )} /> diff --git a/apps/desktop/layer/renderer/src/providers/lazy/index.electron.ts b/apps/desktop/layer/renderer/src/providers/lazy/index.electron.ts index bc1d2e1dd..4389d45ac 100644 --- a/apps/desktop/layer/renderer/src/providers/lazy/index.electron.ts +++ b/apps/desktop/layer/renderer/src/providers/lazy/index.electron.ts @@ -4,3 +4,5 @@ export { ExternalJumpInProvider as LazyExternalJumpInProvider } from "../externa export { LottieRenderContainer as LazyLottieRenderContainer } from "~/components/ui/lottie-container" export const LazyReloadPrompt = () => null export const LazyPWAPrompt = () => null + +export { PopoverProvider as LazyPopoverProvider } from "../popover-provider" diff --git a/apps/desktop/layer/renderer/src/providers/lazy/index.rn.ts b/apps/desktop/layer/renderer/src/providers/lazy/index.rn.ts deleted file mode 100644 index 83f3297b9..000000000 --- a/apps/desktop/layer/renderer/src/providers/lazy/index.rn.ts +++ /dev/null @@ -1,9 +0,0 @@ -export { ContextMenuProvider as LazyContextMenuProvider } from "../context-menu-provider" -export { ExtensionExposeProvider as LazyExtensionExposeProvider } from "../extension-expose-provider" -export { LottieRenderContainer as LazyLottieRenderContainer } from "~/components/ui/lottie-container" -export { ModalStackProvider as LazyModalStackProvider } from "~/components/ui/modal" - -const noop = () => null -export const LazyReloadPrompt = noop -export const LazyPWAPrompt = noop -export const LazyExternalJumpInProvider = noop diff --git a/apps/desktop/layer/renderer/src/providers/lazy/index.ts b/apps/desktop/layer/renderer/src/providers/lazy/index.ts index 196751387..b041eb814 100644 --- a/apps/desktop/layer/renderer/src/providers/lazy/index.ts +++ b/apps/desktop/layer/renderer/src/providers/lazy/index.ts @@ -10,6 +10,11 @@ const LazyContextMenuProvider = lazy(() => default: res.ContextMenuProvider, })), ) +const LazyPopoverProvider = lazy(() => + import("./../popover-provider").then((res) => ({ + default: res.PopoverProvider, + })), +) const LazyExtensionExposeProvider = lazy(() => import("./../extension-expose-provider").then((res) => ({ @@ -47,6 +52,7 @@ export { LazyContextMenuProvider, LazyExtensionExposeProvider, LazyLottieRenderContainer, + LazyPopoverProvider, LazyPWAPrompt, LazyReloadPrompt, } diff --git a/apps/desktop/layer/renderer/src/providers/popover-provider.tsx b/apps/desktop/layer/renderer/src/providers/popover-provider.tsx new file mode 100644 index 000000000..94f9959c8 --- /dev/null +++ b/apps/desktop/layer/renderer/src/providers/popover-provider.tsx @@ -0,0 +1,81 @@ +// Import from the correct path +import { useSetGlobalFocusableScope } from "@follow/components/common/Focusable/hooks.js" +import { Spring } from "@follow/components/constants/spring.js" +import { + Popover, + PopoverArrow, + PopoverContent, + PopoverTrigger, +} from "@follow/components/ui/popover/index.jsx" +import { AnimatePresence, m } from "motion/react" +import { memo, useEffect, useRef } from "react" + +import { usePopoverState } from "~/atoms/popover" +import { HotkeyScope } from "~/constants" + +export const PopoverProvider: Component = ({ children }) => ( + <> + {children} + + +) + +const Handler = memo(() => { + const ref = useRef(null) + const [popoverState, setPopoverState] = usePopoverState() + const setGlobalFocusableScope = useSetGlobalFocusableScope() + + useEffect(() => { + if (!popoverState.open) return + const triggerElement = ref.current + if (!triggerElement) return + + triggerElement.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + cancelable: true, + }), + ) + }, [popoverState]) + + return ( + { + if (state) { + setGlobalFocusableScope(HotkeyScope.DropdownMenu, "append") + } else { + setGlobalFocusableScope(HotkeyScope.DropdownMenu, "remove") + setPopoverState({ open: false }) + } + }} + > + + + + {popoverState.open && ( + + + {popoverState.content} + + )} + + + + ) +}) + +Handler.displayName = "PopoverHandler" diff --git a/apps/desktop/layer/renderer/src/providers/root-providers.tsx b/apps/desktop/layer/renderer/src/providers/root-providers.tsx index 2502fc017..c868c69f6 100644 --- a/apps/desktop/layer/renderer/src/providers/root-providers.tsx +++ b/apps/desktop/layer/renderer/src/providers/root-providers.tsx @@ -24,6 +24,7 @@ import { LazyExtensionExposeProvider, LazyExternalJumpInProvider, LazyLottieRenderContainer, + LazyPopoverProvider, LazyPWAPrompt, LazyReloadPrompt, } from "./lazy/index" @@ -56,6 +57,7 @@ export const RootProviders: FC = ({ children }) => ( + diff --git a/icons/mgc/facebook_cute_re.svg b/icons/mgc/facebook_cute_re.svg new file mode 100644 index 000000000..738bf465d --- /dev/null +++ b/icons/mgc/facebook_cute_re.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/mgc/telegram_cute_fi.svg b/icons/mgc/telegram_cute_fi.svg new file mode 100644 index 000000000..f614df6a4 --- /dev/null +++ b/icons/mgc/telegram_cute_fi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/mgc/telegram_cute_re.svg b/icons/mgc/telegram_cute_re.svg new file mode 100644 index 000000000..32282cc79 --- /dev/null +++ b/icons/mgc/telegram_cute_re.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/mgc/weibo_cute_re.svg b/icons/mgc/weibo_cute_re.svg new file mode 100644 index 000000000..98b776f2a --- /dev/null +++ b/icons/mgc/weibo_cute_re.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/locales/app/en.json b/locales/app/en.json index 6bd8e4d14..227d397c8 100644 --- a/locales/app/en.json +++ b/locales/app/en.json @@ -136,16 +136,14 @@ "entry_actions.save_to_obsidian": "Save to Obsidian", "entry_actions.save_to_outline": "Save to Outline", "entry_actions.save_to_readeck": "Save to Readeck", - "entry_actions.save_to_readwise": "Save to Readwise", - "entry_actions.save_to_zotero": "Save to Zotero", + "entry_actions.save_to_readwise": "Saved to Readwise.", + "entry_actions.save_to_zotero": "Saved to Zotero", "entry_actions.saved_to_cubox": "Saved to Cubox", "entry_actions.saved_to_eagle": "Saved to Eagle.", "entry_actions.saved_to_instapaper": "Saved to Instapaper.", "entry_actions.saved_to_obsidian": "Saved to Obsidian", "entry_actions.saved_to_outline": "Saved to Outline.", "entry_actions.saved_to_readeck": "Saved to Readeck.", - "entry_actions.saved_to_readwise": "Saved to Readwise.", - "entry_actions.saved_to_zotero": "Saved to Zotero", "entry_actions.share": "Share", "entry_actions.star": "Star / UnStar", "entry_actions.starred": "Starred.", @@ -337,6 +335,16 @@ "search.placeholder": "Search...", "search.result_count_local_mode": "(Local mode)", "search.tooltip.local_search": "This search covers locally available data. Try a Refetch to include the latest data.", + "share.actions": "Actions", + "share.copy_failed": "Failed to copy link", + "share.copy_link": "Copy Link", + "share.default_description": "Check out this entry", + "share.default_title": "Entry Share", + "share.discover_more": "Discover more on Follow", + "share.link_copied": "Link copied to clipboard", + "share.social_media": "Social Media", + "share.system_share": "System Share", + "share.title": "Share Entry", "shortcuts.guide.title": "Shortcuts Guideline", "sidebar.add_more_feeds": "Add more feeds", "sidebar.category_remove_dialog.cancel": "Cancel", diff --git a/locales/app/ja.json b/locales/app/ja.json index fa8c1ed9f..ccf489477 100644 --- a/locales/app/ja.json +++ b/locales/app/ja.json @@ -120,7 +120,6 @@ "entry_actions.saved_to_obsidian": "Obsidian に保存されました。", "entry_actions.saved_to_outline": "Outline に保存されました。", "entry_actions.saved_to_readeck": "Readeck に保存されました。", - "entry_actions.saved_to_readwise": "Readwise に保存されました。", "entry_actions.share": "共有", "entry_actions.star": "スター", "entry_actions.starred": "スターに追加されました。", diff --git a/locales/app/zh-CN.json b/locales/app/zh-CN.json index 948fad8f7..e1e228f0c 100644 --- a/locales/app/zh-CN.json +++ b/locales/app/zh-CN.json @@ -143,8 +143,6 @@ "entry_actions.saved_to_obsidian": "已保存到 Obsidian。", "entry_actions.saved_to_outline": "已保存到 Outline。", "entry_actions.saved_to_readeck": "已保存到 Readeck。", - "entry_actions.saved_to_readwise": "已保存到 Readwise。", - "entry_actions.saved_to_zotero": "已保存到 Zotero。", "entry_actions.share": "分享", "entry_actions.star": "收藏", "entry_actions.starred": "已收藏", @@ -336,6 +334,16 @@ "search.placeholder": "搜索...", "search.result_count_local_mode": "(本地模式)", "search.tooltip.local_search": "当前搜索仅包含本地可用数据,尝试重新搜索得到更多结果。", + "share.actions": "操作", + "share.copy_failed": "复制链接失败", + "share.copy_link": "复制链接", + "share.default_description": "查看这个精彩内容", + "share.default_title": "内容分享", + "share.discover_more": "在 Follow 上发现更多精彩内容", + "share.link_copied": "链接已复制到剪贴板", + "share.social_media": "社交媒体", + "share.system_share": "系统分享", + "share.title": "分享内容", "shortcuts.guide.title": "快捷键指南", "sidebar.add_more_feeds": "添加订阅源", "sidebar.category_remove_dialog.cancel": "取消", diff --git a/locales/app/zh-TW.json b/locales/app/zh-TW.json index e2895f5ce..26437725d 100644 --- a/locales/app/zh-TW.json +++ b/locales/app/zh-TW.json @@ -141,8 +141,6 @@ "entry_actions.saved_to_obsidian": "已儲存到 Obsidian。", "entry_actions.saved_to_outline": "已儲存到 Outline。", "entry_actions.saved_to_readeck": "已儲存到 Readeck。", - "entry_actions.saved_to_readwise": "已儲存到 Readwise。", - "entry_actions.saved_to_zotero": "已儲存到 Zotero。", "entry_actions.share": "分享", "entry_actions.star": "收藏", "entry_actions.starred": "已收藏", diff --git a/packages/internal/components/src/atoms/mouse.ts b/packages/internal/components/src/atoms/mouse.ts new file mode 100644 index 000000000..9b3d0cbe1 --- /dev/null +++ b/packages/internal/components/src/atoms/mouse.ts @@ -0,0 +1,6 @@ +import { atom } from "jotai" + +export const mouseAtom = atom({ + x: 0, + y: 0, +}) diff --git a/packages/internal/components/src/hooks/useMouse.ts b/packages/internal/components/src/hooks/useMouse.ts new file mode 100644 index 000000000..da4cce631 --- /dev/null +++ b/packages/internal/components/src/hooks/useMouse.ts @@ -0,0 +1,10 @@ +import { jotaiStore } from "@follow/utils" +import { useAtomValue } from "jotai" + +import { mouseAtom } from "../atoms/mouse" + +export const useMousePosition = () => { + return useAtomValue(mouseAtom) +} + +export const getMousePosition = () => jotaiStore.get(mouseAtom) diff --git a/packages/internal/components/src/providers/event-provider.tsx b/packages/internal/components/src/providers/event-provider.tsx index 2367f8908..a3455e609 100644 --- a/packages/internal/components/src/providers/event-provider.tsx +++ b/packages/internal/components/src/providers/event-provider.tsx @@ -4,6 +4,8 @@ import { useIsomorphicLayoutEffect } from "foxact/use-isomorphic-layout-effect" import { useStore } from "jotai" import type { FC } from "react" +import { mouseAtom } from "../atoms/mouse" + export const EventProvider: FC = () => { const store = useStore() useIsomorphicLayoutEffect(() => { @@ -36,5 +38,13 @@ export const EventProvider: FC = () => { } }, []) + useIsomorphicLayoutEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + store.set(mouseAtom, { x: e.clientX, y: e.clientY }) + } + window.addEventListener("mousemove", handleMouseMove) + return () => window.removeEventListener("mousemove", handleMouseMove) + }, [store]) + return null } diff --git a/packages/internal/components/src/ui/popover/index.tsx b/packages/internal/components/src/ui/popover/index.tsx index 60856a5e9..584d11a98 100644 --- a/packages/internal/components/src/ui/popover/index.tsx +++ b/packages/internal/components/src/ui/popover/index.tsx @@ -11,6 +11,8 @@ const PopoverTrigger = PopoverPrimitive.Trigger const PopoverClose = PopoverPrimitive.Close +const PopoverArrow = PopoverPrimitive.Arrow + const PopoverContent = ({ ref, className, @@ -37,4 +39,4 @@ const PopoverContent = ({ ) PopoverContent.displayName = PopoverPrimitive.Content.displayName -export { Popover, PopoverClose, PopoverContent, PopoverTrigger } +export { Popover, PopoverArrow, PopoverClose, PopoverContent, PopoverTrigger }