fix(desktop): dismiss share popover after actions

This commit is contained in:
DIYgod 2026-07-25 20:19:15 +08:00
parent 3d65478fb6
commit 52f587dacb
5 changed files with 344 additions and 26 deletions

View File

@ -9,7 +9,7 @@ import { createAtomHooks, jotaiStore } from "~/lib/jotai"
export interface PopoverProps extends Omit<PopoverContentProps, "children"> {
/** 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 })
}

View File

@ -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(<SharePanel entryId="entry-1" />)
})
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()
})
})

View File

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

View File

@ -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(
<Provider store={jotaiStore}>
<GlobalFocusableProvider>
<PopoverProvider>
<div>App content</div>
</PopoverProvider>
</GlobalFocusableProvider>
</Provider>,
)
})
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 }, <div>Share content</div>)
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 }, <div>First popover</div>)
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 }, <div>Second popover</div>)
await waitForPopoverEffects()
})
expect(jotaiStore.get(popoverAtom).open).toBe(true)
expect(document.body.textContent).toContain("Second popover")
})
})

View File

@ -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<HTMLButtonElement>(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 (
<Popover
open={popoverState.open}
modal={modal}
onOpenChange={(state) => {
if (state) {
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "append")
} else {
setGlobalFocusableScope(HotkeyScope.DropdownMenu, "remove")
setPopoverState({ open: false })
if (!state) {
dismissPopover()
}
}}
>
<PopoverTrigger
ref={ref}
className="pointer-events-none"
style={
popoverState.open
@ -58,9 +53,14 @@ const Handler = memo(() => {
: {}
}
/>
<PopoverContent asChild forceMount>
<AnimatePresence>
{popoverState.open && (
<AnimatePresence>
{popoverState.open && (
<PopoverContent
{...contentProps}
asChild
forceMount
style={{ ...contentProps.style, zIndex }}
>
<m.div
className="mr-2 rounded-xl border bg-material-ultra-thick p-2 shadow-2xl backdrop-blur-background"
initial={{ opacity: 0, scale: 0.95, y: -10 }}
@ -71,9 +71,9 @@ const Handler = memo(() => {
<PopoverArrow className="fill-border" />
{popoverState.content}
</m.div>
)}
</AnimatePresence>
</PopoverContent>
</PopoverContent>
)}
</AnimatePresence>
</Popover>
)
})