diff --git a/apps/desktop/layer/renderer/src/atoms/popover.ts b/apps/desktop/layer/renderer/src/atoms/popover.ts index 01138ee0c..edc3015b0 100644 --- a/apps/desktop/layer/renderer/src/atoms/popover.ts +++ b/apps/desktop/layer/renderer/src/atoms/popover.ts @@ -9,7 +9,7 @@ import { createAtomHooks, jotaiStore } from "~/lib/jotai" export interface PopoverProps extends Omit { /** Custom z-index for popover */ zIndex?: number - /** Whether the popover should close when clicked outside */ + /** Whether the popover should use modal focus and pointer behavior */ modal?: boolean } @@ -33,6 +33,11 @@ export const showPopover = ( element: ReactNode, props?: PopoverProps, ) => { + const currentPopover = jotaiStore.get(popoverAtom) + if (currentPopover.open) { + currentPopover.abortController.abort() + } + jotaiStore.set(popoverAtom, { open: true, position: mouseXY, @@ -41,3 +46,11 @@ export const showPopover = ( abortController: new AbortController(), }) } + +export const dismissPopover = () => { + const currentPopover = jotaiStore.get(popoverAtom) + if (!currentPopover.open) return + + currentPopover.abortController.abort() + jotaiStore.set(popoverAtom, { open: false }) +} diff --git a/apps/desktop/layer/renderer/src/components/common/SharePanel.test.tsx b/apps/desktop/layer/renderer/src/components/common/SharePanel.test.tsx new file mode 100644 index 000000000..0d458439b --- /dev/null +++ b/apps/desktop/layer/renderer/src/components/common/SharePanel.test.tsx @@ -0,0 +1,153 @@ +import * as React from "react" +import { act } from "react" +import type { Root } from "react-dom/client" +import { createRoot } from "react-dom/client" +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest" + +import { SharePanel } from "./SharePanel" + +const mocks = vi.hoisted(() => ({ + copyToClipboard: vi.fn(), + dismissPopover: vi.fn(), + getEntry: vi.fn(), + toastError: vi.fn(), + toastSuccess: vi.fn(), +})) + +vi.mock("@follow/store/entry/getter", () => ({ + getEntry: mocks.getEntry, +})) + +vi.mock("~/atoms/popover", () => ({ + dismissPopover: mocks.dismissPopover, +})) + +vi.mock("~/lib/client", () => ({ + ipcServices: undefined, +})) + +vi.mock("~/lib/clipboard", () => ({ + copyToClipboard: mocks.copyToClipboard, +})) + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +vi.mock("sonner", () => ({ + toast: { + error: mocks.toastError, + success: mocks.toastSuccess, + }, +})) + +const waitForShareAction = async () => { + for (let index = 0; index < 2; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +const renderSharePanel = async () => { + const container = document.createElement("div") + document.body.append(container) + + const root = createRoot(container) + await act(async () => { + root.render() + }) + + return { container, root } +} + +const clickAction = async (container: HTMLElement, label: string) => { + const button = Array.from(container.querySelectorAll("button")).find((element) => + element.textContent?.includes(label), + ) + expect(button).not.toBeUndefined() + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true })) + await waitForShareAction() + }) +} + +describe("SharePanel", () => { + let root: Root | null = null + let container: HTMLElement | null = null + + beforeAll(() => { + ;(globalThis as typeof globalThis & { React: typeof React }).React = React + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + }) + + beforeEach(() => { + mocks.getEntry.mockReturnValue({ + description: "Example description", + id: "entry-1", + title: "Example entry", + url: "https://example.com/article", + }) + mocks.copyToClipboard.mockResolvedValue(undefined) + }) + + afterAll(() => { + vi.restoreAllMocks() + }) + + afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount() + }) + } + + container?.remove() + document.body.innerHTML = "" + root = null + container = null + Reflect.deleteProperty(navigator, "share") + vi.clearAllMocks() + }) + + test("dismisses after copying the link", async () => { + ;({ container, root } = await renderSharePanel()) + + await clickAction(container, "share.copy_link") + + expect(mocks.copyToClipboard).toHaveBeenCalledWith("https://example.com/article") + expect(mocks.toastSuccess).toHaveBeenCalledWith("share.link_copied") + expect(mocks.dismissPopover).toHaveBeenCalledOnce() + }) + + test("keeps the panel open when copying the link fails", async () => { + mocks.copyToClipboard.mockRejectedValueOnce(new Error("Clipboard unavailable")) + ;({ container, root } = await renderSharePanel()) + + await clickAction(container, "share.copy_link") + + expect(mocks.toastError).toHaveBeenCalledWith("share.copy_failed") + expect(mocks.dismissPopover).not.toHaveBeenCalled() + }) + + test("dismisses after system sharing completes", async () => { + const share = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, "share", { + configurable: true, + value: share, + }) + ;({ container, root } = await renderSharePanel()) + + await clickAction(container, "share.system_share") + + expect(share).toHaveBeenCalledWith({ + text: "Example description | share.discover_more", + title: "Example entry - Folo", + url: "https://example.com/article", + }) + expect(mocks.dismissPopover).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/desktop/layer/renderer/src/components/common/SharePanel.tsx b/apps/desktop/layer/renderer/src/components/common/SharePanel.tsx index 22b1e5512..40e95f47c 100644 --- a/apps/desktop/layer/renderer/src/components/common/SharePanel.tsx +++ b/apps/desktop/layer/renderer/src/components/common/SharePanel.tsx @@ -5,6 +5,7 @@ import { useCallback } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" +import { dismissPopover } from "~/atoms/popover" import { ipcServices } from "~/lib/client" import { copyToClipboard } from "~/lib/clipboard" @@ -140,11 +141,13 @@ export const SharePanel = ({ entryId }: SharePanelProps) => { await copyToClipboard(shareContent.url) toast.success(t("share.link_copied")) } + dismissPopover() } catch { // If sharing fails, copy link as fallback try { await copyToClipboard(shareContent.url) toast.success(t("share.link_copied")) + dismissPopover() } catch { toast.error(t("share.copy_failed")) } @@ -156,6 +159,7 @@ export const SharePanel = ({ entryId }: SharePanelProps) => { try { await copyToClipboard(shareUrl) toast.success(t("share.link_copied")) + dismissPopover() } catch { toast.error(t("share.copy_failed")) } @@ -178,6 +182,7 @@ export const SharePanel = ({ entryId }: SharePanelProps) => { .replace("{text}", shareText) window.open(finalUrl, "_blank", "width=600,height=400") + dismissPopover() }, [entryId, generateShareContent], ) diff --git a/apps/desktop/layer/renderer/src/providers/popover-provider.test.tsx b/apps/desktop/layer/renderer/src/providers/popover-provider.test.tsx new file mode 100644 index 000000000..a802aee74 --- /dev/null +++ b/apps/desktop/layer/renderer/src/providers/popover-provider.test.tsx @@ -0,0 +1,147 @@ +import { GlobalFocusableProvider } from "@follow/components/common/Focusable/GlobalFocusableProvider.js" +import { Provider } from "jotai" +import * as React from "react" +import { act } from "react" +import type { Root } from "react-dom/client" +import { createRoot } from "react-dom/client" +import { afterAll, afterEach, beforeAll, describe, expect, test, vi } from "vitest" + +import { dismissPopover, popoverAtom, showPopover } from "~/atoms/popover" +import { jotaiStore } from "~/lib/jotai" + +import { PopoverProvider } from "./popover-provider" + +const waitForPopoverEffects = async () => { + for (let index = 0; index < 3; index += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)) + } +} + +const renderProvider = async () => { + const container = document.createElement("div") + document.body.append(container) + + const root = createRoot(container) + await act(async () => { + root.render( + + + +
App content
+
+
+
, + ) + }) + + return { container, root } +} + +describe("PopoverProvider", () => { + let root: Root | null = null + let container: HTMLElement | null = null + + beforeAll(() => { + ;(globalThis as typeof globalThis & { React: typeof React }).React = React + ;( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true + vi.spyOn(console, "info").mockImplementation(() => {}) + + Object.assign(window, { + addEventListener: document.defaultView?.addEventListener.bind(document.defaultView), + clearTimeout, + Element: document.defaultView?.Element ?? Element, + getComputedStyle: + document.defaultView?.getComputedStyle.bind(document.defaultView) ?? getComputedStyle, + HTMLElement: document.defaultView?.HTMLElement ?? HTMLElement, + innerHeight: 768, + innerWidth: 1024, + Node: document.defaultView?.Node ?? Node, + removeEventListener: document.defaultView?.removeEventListener.bind(document.defaultView), + setTimeout, + }) + }) + + afterAll(() => { + vi.restoreAllMocks() + }) + + afterEach(async () => { + await act(async () => { + jotaiStore.set(popoverAtom, { open: false }) + await waitForPopoverEffects() + }) + + if (root) { + await act(async () => { + root?.unmount() + }) + } + + container?.remove() + document.body.innerHTML = "" + root = null + container = null + vi.clearAllMocks() + }) + + test("closes when clicking outside", async () => { + ;({ container, root } = await renderProvider()) + + await act(async () => { + showPopover({ x: 120, y: 80 },
Share content
) + await waitForPopoverEffects() + }) + + expect(document.body.textContent).toContain("Share content") + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + + const appContent = Array.from(container.querySelectorAll("div")).find( + (element) => element.textContent === "App content", + ) + expect(appContent).not.toBeUndefined() + + await act(async () => { + for (const eventType of ["pointerdown", "pointerup", "click"]) { + appContent?.dispatchEvent( + new PointerEvent(eventType, { + bubbles: true, + button: 0, + cancelable: true, + }), + ) + } + await waitForPopoverEffects() + }) + + expect(jotaiStore.get(popoverAtom).open).toBe(false) + expect(document.body.textContent).not.toContain("Share content") + }) + + test("can reopen after a programmatic dismissal", async () => { + ;({ container, root } = await renderProvider()) + + await act(async () => { + showPopover({ x: 120, y: 80 },
First popover
) + await waitForPopoverEffects() + }) + + expect(document.body.textContent).toContain("First popover") + + await act(async () => { + dismissPopover() + await waitForPopoverEffects() + }) + + expect(document.body.textContent).not.toContain("First popover") + + await act(async () => { + showPopover({ x: 140, y: 100 },
Second popover
) + await waitForPopoverEffects() + }) + + expect(jotaiStore.get(popoverAtom).open).toBe(true) + expect(document.body.textContent).toContain("Second popover") + }) +}) diff --git a/apps/desktop/layer/renderer/src/providers/popover-provider.tsx b/apps/desktop/layer/renderer/src/providers/popover-provider.tsx index 057912265..8eb7d90a8 100644 --- a/apps/desktop/layer/renderer/src/providers/popover-provider.tsx +++ b/apps/desktop/layer/renderer/src/providers/popover-provider.tsx @@ -8,9 +8,9 @@ import { PopoverTrigger, } from "@follow/components/ui/popover/index.jsx" import { AnimatePresence, m } from "motion/react" -import { memo, useEffect, useRef } from "react" +import { memo, useEffect } from "react" -import { usePopoverState } from "~/atoms/popover" +import { dismissPopover, usePopoverValue } from "~/atoms/popover" import { HotkeyScope } from "~/constants" export const PopoverProvider: Component = ({ children }) => ( @@ -21,36 +21,31 @@ export const PopoverProvider: Component = ({ children }) => ( ) const Handler = memo(() => { - const ref = useRef(null) - const [popoverState, setPopoverState] = usePopoverState() + const popoverState = usePopoverValue() 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]) + setGlobalFocusableScope(HotkeyScope.DropdownMenu, "append") + return () => { + setGlobalFocusableScope(HotkeyScope.DropdownMenu, "remove") + } + }, [popoverState.open, setGlobalFocusableScope]) + + const { modal, zIndex, ...contentProps } = popoverState.open ? (popoverState.props ?? {}) : {} return ( { - if (state) { - setGlobalFocusableScope(HotkeyScope.DropdownMenu, "append") - } else { - setGlobalFocusableScope(HotkeyScope.DropdownMenu, "remove") - setPopoverState({ open: false }) + if (!state) { + dismissPopover() } }} > { : {} } /> - - - {popoverState.open && ( + + {popoverState.open && ( + { {popoverState.content} - )} - - + + )} + ) })