feat: what new (#1172)
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@read-frog/extension": patch
|
||||
---
|
||||
|
||||
feat(sidebar): move What's New into a footer popover with blog previews.
|
||||
|
After Width: | Height: | Size: 696 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 429 B After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 801 B After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 13 KiB |
@@ -1,60 +0,0 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg
|
||||
t="1746996704863"
|
||||
class="icon"
|
||||
viewBox="0 0 1219 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="238.0859375"
|
||||
height="200"
|
||||
p-id="3618"
|
||||
>
|
||||
<!-- 气泡黑色描边 -->
|
||||
<path
|
||||
d="M620.054335 997.952365l-177.169152-162.618664H272.727407A235.333407 235.333407 0 0 1 37.695564 600.264163V253.879625A235.333407 235.333407 0 0 1 272.727407 18.847782h692.769077a235.333407 235.333407 0 0 1 235.031843 235.031843v346.384538a235.333407 235.333407 0 0 1-235.031843 235.031843H784.067734z"
|
||||
fill="#0F0F0F"
|
||||
p-id="3619"
|
||||
/>
|
||||
<!-- 白色填充(仅位于描边内部) -->
|
||||
<path
|
||||
d="M620.054335 997.952365
|
||||
m-347.289233-904.693539
|
||||
a160.997754 160.997754 0 0 0 -160.846972 160.620799
|
||||
v346.384538
|
||||
a160.997754 160.997754 0 0 0 160.809277 160.809277
|
||||
h198.957187
|
||||
l146.522658 134.309295
|
||||
135.28938 -134.271599
|
||||
h212.18833
|
||||
a160.997754 160.997754 0 0 0 160.809277 -160.809277
|
||||
v-346.422234
|
||||
a160.997754 160.997754 0 0 0 -160.809277 -160.809277
|
||||
z"
|
||||
fill="#FFFFFF"
|
||||
/>
|
||||
<!-- 省略号 -->
|
||||
<path
|
||||
d="M384.080103 472.438505a37.092435 37.092435 0 0 1-37.130131-37.13013v-49.45658a37.130131 37.130131 0 1 1 74.222566 0v49.45658a37.092435 37.092435 0 0 1-37.092435 37.13013z"
|
||||
fill="#F15549"
|
||||
p-id="3620"
|
||||
/>
|
||||
<path
|
||||
d="M631.513786 472.438505a37.092435 37.092435 0 0 1-37.130131-37.13013v-49.45658a37.130131 37.130131 0 1 1 74.222566 0v49.45658a37.092435 37.092435 0 0 1-37.092435 37.13013z"
|
||||
fill="#F15549"
|
||||
p-id="3621"
|
||||
/>
|
||||
<path
|
||||
d="M854.181484 472.438505a37.092435 37.092435 0 0 1-37.130131-37.13013v-49.45658a37.130131 37.130131 0 1 1 74.222566 0v49.45658a37.092435 37.092435 0 0 1-37.092435 37.13013z"
|
||||
fill="#F15549"
|
||||
p-id="3622"
|
||||
/>
|
||||
<!-- 背景装饰圆 -->
|
||||
<path
|
||||
d="M433.498988 1.168562a433.498988 433.498988 0 0 1 433.498987 433.498988 438.022455 438.022455 0 0 1-216.447929 375.334732 421.059452 421.059452 0 0 1-217.051058 58.164256 433.498988 433.498988 0 0 1 0-866.997976z"
|
||||
fill="#FF8D84"
|
||||
opacity=".19"
|
||||
p-id="3623"
|
||||
/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 62 KiB |
@@ -153,7 +153,7 @@ function SelectionPopoverRoot({
|
||||
setPinned,
|
||||
triggerElement,
|
||||
setTriggerElement,
|
||||
}), [anchor, open, pinned, setOpen, triggerElement])
|
||||
}), [anchor, open, pinned, setAnchor, setOpen, triggerElement])
|
||||
|
||||
return (
|
||||
<SelectionPopoverRootContext value={contextValue}>
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { ReactNode } from "react"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
|
||||
import * as React from "react"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { WhatsNewFooter } from "../whats-new-footer"
|
||||
|
||||
const getLastViewedBlogDateMock = vi.fn()
|
||||
const getLatestBlogDateMock = vi.fn()
|
||||
const saveLastViewedBlogDateMock = vi.fn()
|
||||
|
||||
vi.mock("#imports", () => ({
|
||||
i18n: {
|
||||
t: (key: string) => key,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@/components/ui/base-ui/sidebar", () => ({
|
||||
SidebarMenu: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
SidebarMenuButton: ({ children, ...props }: React.ComponentProps<"button">) => (
|
||||
<button type="button" {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
SidebarMenuItem: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
vi.mock("@/components/ui/base-ui/popover", async () => {
|
||||
const React = await import("react")
|
||||
|
||||
interface PopoverContextValue {
|
||||
open: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
const PopoverContext = React.createContext<PopoverContextValue | null>(null)
|
||||
|
||||
function usePopoverContext() {
|
||||
const context = React.use(PopoverContext)
|
||||
if (!context) {
|
||||
throw new Error("Popover components must be used within Popover.")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
function Popover({
|
||||
children,
|
||||
open = false,
|
||||
onOpenChange,
|
||||
}: {
|
||||
children: ReactNode
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<PopoverContext value={{ open, onOpenChange }}>
|
||||
{children}
|
||||
</PopoverContext>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
children,
|
||||
render,
|
||||
}: {
|
||||
children: ReactNode
|
||||
render?: React.ReactElement<React.ComponentProps<"button">>
|
||||
}) {
|
||||
const { open, onOpenChange } = usePopoverContext()
|
||||
|
||||
if (render && React.isValidElement(render)) {
|
||||
const originalOnClick = render.props.onClick
|
||||
|
||||
// eslint-disable-next-line react/no-clone-element
|
||||
return React.cloneElement(render, {
|
||||
children,
|
||||
onClick: (event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
originalOnClick?.(event)
|
||||
onOpenChange?.(!open)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" onClick={() => onOpenChange?.(!open)}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverContent({ children }: { children: ReactNode }) {
|
||||
const { open } = usePopoverContext()
|
||||
return open ? <div data-testid="whats-new-popover-content">{children}</div> : null
|
||||
}
|
||||
|
||||
return {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
PopoverHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
PopoverTitle: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
PopoverTrigger,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock("@/utils/blog", () => ({
|
||||
buildBilibiliEmbedUrl: vi.fn(() => null),
|
||||
getLastViewedBlogDate: (...args: unknown[]) => getLastViewedBlogDateMock(...args),
|
||||
getLatestBlogDate: (...args: unknown[]) => getLatestBlogDateMock(...args),
|
||||
hasNewBlogPost: (latestViewedDate: Date | null, latestDate: Date | null) => {
|
||||
if (!latestDate) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!latestViewedDate) {
|
||||
return true
|
||||
}
|
||||
|
||||
return latestDate > latestViewedDate
|
||||
},
|
||||
saveLastViewedBlogDate: (...args: unknown[]) => saveLastViewedBlogDateMock(...args),
|
||||
}))
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res
|
||||
})
|
||||
|
||||
return {
|
||||
promise,
|
||||
resolve,
|
||||
}
|
||||
}
|
||||
|
||||
function createQueryClient() {
|
||||
return new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
gcTime: 0,
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function renderWhatsNewFooter() {
|
||||
const queryClient = createQueryClient()
|
||||
|
||||
return {
|
||||
queryClient,
|
||||
...render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WhatsNewFooter />
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
const latestBlogPost = {
|
||||
date: new Date("2026-03-20T12:00:00.000Z"),
|
||||
description: "Latest updates",
|
||||
title: "Spring release",
|
||||
url: "/blog/spring-release",
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("whatsNewFooter", () => {
|
||||
it("auto-opens and marks the post as viewed when unread status arrives after the post", async () => {
|
||||
const latestBlogPostDeferred = createDeferred<typeof latestBlogPost | null>()
|
||||
const lastViewedDateDeferred = createDeferred<Date | null>()
|
||||
|
||||
getLatestBlogDateMock.mockReturnValueOnce(latestBlogPostDeferred.promise)
|
||||
getLastViewedBlogDateMock
|
||||
.mockReturnValueOnce(lastViewedDateDeferred.promise)
|
||||
.mockResolvedValueOnce(latestBlogPost.date)
|
||||
saveLastViewedBlogDateMock.mockResolvedValue(undefined)
|
||||
|
||||
renderWhatsNewFooter()
|
||||
|
||||
await act(async () => {
|
||||
latestBlogPostDeferred.resolve(latestBlogPost)
|
||||
})
|
||||
|
||||
await screen.findByRole("button", { name: "options.whatsNew.title" })
|
||||
expect(screen.queryByTestId("whats-new-popover-content")).not.toBeInTheDocument()
|
||||
expect(saveLastViewedBlogDateMock).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
lastViewedDateDeferred.resolve(null)
|
||||
})
|
||||
|
||||
await screen.findByTestId("whats-new-popover-content")
|
||||
await waitFor(() => {
|
||||
expect(saveLastViewedBlogDateMock).toHaveBeenCalledTimes(1)
|
||||
expect(saveLastViewedBlogDateMock).toHaveBeenCalledWith(latestBlogPost.date)
|
||||
})
|
||||
})
|
||||
|
||||
it("does not auto-open or mark the post as viewed when it is already read", async () => {
|
||||
getLatestBlogDateMock.mockResolvedValue(latestBlogPost)
|
||||
getLastViewedBlogDateMock.mockResolvedValue(
|
||||
new Date("2026-03-21T12:00:00.000Z"),
|
||||
)
|
||||
saveLastViewedBlogDateMock.mockResolvedValue(undefined)
|
||||
|
||||
renderWhatsNewFooter()
|
||||
|
||||
await screen.findByRole("button", { name: "options.whatsNew.title" })
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId("whats-new-popover-content")).not.toBeInTheDocument()
|
||||
expect(saveLastViewedBlogDateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("marks the post as viewed after a manual open once the unread query finishes", async () => {
|
||||
const lastViewedDateDeferred = createDeferred<Date | null>()
|
||||
|
||||
getLatestBlogDateMock.mockResolvedValue(latestBlogPost)
|
||||
getLastViewedBlogDateMock
|
||||
.mockReturnValueOnce(lastViewedDateDeferred.promise)
|
||||
.mockResolvedValueOnce(latestBlogPost.date)
|
||||
saveLastViewedBlogDateMock.mockResolvedValue(undefined)
|
||||
|
||||
renderWhatsNewFooter()
|
||||
|
||||
const trigger = await screen.findByRole("button", { name: "options.whatsNew.title" })
|
||||
fireEvent.click(trigger)
|
||||
|
||||
expect(await screen.findByTestId("whats-new-popover-content")).toBeInTheDocument()
|
||||
expect(saveLastViewedBlogDateMock).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
lastViewedDateDeferred.resolve(null)
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(saveLastViewedBlogDateMock).toHaveBeenCalledTimes(1)
|
||||
expect(saveLastViewedBlogDateMock).toHaveBeenCalledWith(latestBlogPost.date)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import { Kbd } from "@/components/ui/base-ui/kbd"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
} from "@/components/ui/base-ui/sidebar"
|
||||
import { getCommandPaletteShortcutHint } from "@/utils/os"
|
||||
@@ -19,6 +20,7 @@ import { commandPaletteOpenAtom } from "../command-palette/atoms"
|
||||
import { ProductNav } from "./product-nav"
|
||||
import { SettingsNav } from "./settings-nav"
|
||||
import { ToolsNav } from "./tools-nav"
|
||||
import { WhatsNewFooter } from "./whats-new-footer"
|
||||
|
||||
export function AppSidebar() {
|
||||
const setCommandPaletteOpen = useSetAtom(commandPaletteOpenAtom)
|
||||
@@ -44,9 +46,12 @@ export function AppSidebar() {
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
<InputGroupAddon>
|
||||
<IconSearch className="size-4 text-muted-foreground" />
|
||||
<IconSearch className="size-4 text-muted-foreground group-data-[state=collapsed]:-mx-px" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupAddon
|
||||
align="inline-end"
|
||||
className="group-data-[state=collapsed]:hidden"
|
||||
>
|
||||
<Kbd>{commandPaletteShortcutHint}</Kbd>
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
@@ -56,6 +61,9 @@ export function AppSidebar() {
|
||||
<ToolsNav />
|
||||
<ProductNav />
|
||||
</SidebarContent>
|
||||
<SidebarFooter className="group-data-[state=expanded]:px-2 transition-all">
|
||||
<WhatsNewFooter />
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,11 +10,8 @@ import {
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/base-ui/sidebar"
|
||||
import { getLastViewedBlogDate, getLatestBlogDate, hasNewBlogPost, saveLastViewedBlogDate } from "@/utils/blog"
|
||||
import { WEBSITE_URL } from "@/utils/constants/url"
|
||||
import { cn } from "@/utils/styles/utils"
|
||||
import { getLastViewedSurvey, hasNewSurvey, saveLastViewedSurvey } from "@/utils/survey"
|
||||
import { version } from "../../../../package.json"
|
||||
import { AnimatedIndicator } from "./animated-indicator"
|
||||
|
||||
const SURVEY_URL = "https://tally.so/r/kdNN5R"
|
||||
@@ -23,60 +20,23 @@ export function ProductNav() {
|
||||
const { open } = useSidebar()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: lastViewedDate } = useQuery({
|
||||
queryKey: ["last-viewed-blog-date"],
|
||||
queryFn: getLastViewedBlogDate,
|
||||
})
|
||||
|
||||
const { data: latestBlogPost } = useQuery({
|
||||
queryKey: ["latest-blog-post"],
|
||||
queryFn: () => getLatestBlogDate(`${WEBSITE_URL}/api/blog/latest`, "en", version),
|
||||
})
|
||||
|
||||
const { data: lastViewedSurveyUrl } = useQuery({
|
||||
queryKey: ["last-viewed-survey"],
|
||||
queryFn: getLastViewedSurvey,
|
||||
})
|
||||
|
||||
const handleWhatsNewClick = async () => {
|
||||
if (latestBlogPost) {
|
||||
await saveLastViewedBlogDate(latestBlogPost.date)
|
||||
await queryClient.invalidateQueries({ queryKey: ["last-viewed-blog-date"] })
|
||||
}
|
||||
}
|
||||
|
||||
const handleSurveyClick = async () => {
|
||||
await saveLastViewedSurvey(SURVEY_URL)
|
||||
await queryClient.invalidateQueries({ queryKey: ["last-viewed-survey"] })
|
||||
}
|
||||
|
||||
const showBlogIndicator = hasNewBlogPost(
|
||||
lastViewedDate ?? null,
|
||||
latestBlogPost?.date ?? null,
|
||||
)
|
||||
|
||||
const showSurveyIndicator = hasNewSurvey(lastViewedSurveyUrl ?? null, SURVEY_URL)
|
||||
|
||||
const blogUrl = latestBlogPost?.url
|
||||
? `${WEBSITE_URL}${latestBlogPost.url}`
|
||||
: `${WEBSITE_URL}/blog?latest-indicator=true`
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>{i18n.t("options.sidebar.product")}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem className="relative">
|
||||
<SidebarMenuButton
|
||||
render={<a href={blogUrl} target="_blank" rel="noopener noreferrer" onClick={handleWhatsNewClick} />}
|
||||
className={cn(showBlogIndicator && "text-primary font-semibold hover:text-primary active:text-primary")}
|
||||
>
|
||||
<Icon icon="tabler:sparkles" />
|
||||
<span>{i18n.t("options.whatsNew.title")}</span>
|
||||
</SidebarMenuButton>
|
||||
<AnimatedIndicator show={showBlogIndicator && open} />
|
||||
</SidebarMenuItem>
|
||||
|
||||
<SidebarMenuItem className="relative">
|
||||
<SidebarMenuButton
|
||||
render={<a href={SURVEY_URL} target="_blank" rel="noopener noreferrer" onClick={handleSurveyClick} />}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { i18n } from "#imports"
|
||||
import { Icon } from "@iconify/react"
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { useCallback, useEffect, useEffectEvent, useState } from "react"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/base-ui/popover"
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/base-ui/sidebar"
|
||||
import {
|
||||
buildBilibiliEmbedUrl,
|
||||
getLastViewedBlogDate,
|
||||
getLatestBlogDate,
|
||||
hasNewBlogPost,
|
||||
saveLastViewedBlogDate,
|
||||
} from "@/utils/blog"
|
||||
import { WEBSITE_URL } from "@/utils/constants/url"
|
||||
import { version } from "../../../../package.json"
|
||||
|
||||
export function WhatsNewFooter() {
|
||||
const queryClient = useQueryClient()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const { data: lastViewedDate, isFetched: isLastViewedDateFetched } = useQuery({
|
||||
queryKey: ["last-viewed-blog-date"],
|
||||
queryFn: getLastViewedBlogDate,
|
||||
})
|
||||
|
||||
const { data: latestBlogPost, isFetched: isLatestBlogPostFetched } = useQuery({
|
||||
queryKey: ["latest-blog-post"],
|
||||
queryFn: () => getLatestBlogDate(`${WEBSITE_URL}/api/blog/latest`, "en", version),
|
||||
})
|
||||
|
||||
const markLatestBlogPostViewed = useEffectEvent(async () => {
|
||||
if (!latestBlogPost) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isLastViewedDateFetched) {
|
||||
return
|
||||
}
|
||||
|
||||
if (lastViewedDate && lastViewedDate.getTime() >= latestBlogPost.date.getTime()) {
|
||||
return
|
||||
}
|
||||
|
||||
await saveLastViewedBlogDate(latestBlogPost.date)
|
||||
await queryClient.invalidateQueries({ queryKey: ["last-viewed-blog-date"] })
|
||||
})
|
||||
|
||||
const handleOpenChange = useCallback((nextOpen: boolean) => {
|
||||
setOpen(nextOpen)
|
||||
}, [])
|
||||
|
||||
const openPopover = useEffectEvent(() => {
|
||||
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
|
||||
setOpen(true)
|
||||
})
|
||||
|
||||
const latestBlogPostDate = latestBlogPost?.date ?? null
|
||||
const latestBlogPostKey = latestBlogPost
|
||||
? `${latestBlogPost.url}:${latestBlogPost.date.toISOString()}`
|
||||
: null
|
||||
const lastViewedDateTimestamp = lastViewedDate?.getTime() ?? null
|
||||
const shouldAutoOpenPopover = isLastViewedDateFetched
|
||||
&& isLatestBlogPostFetched
|
||||
&& hasNewBlogPost(lastViewedDate ?? null, latestBlogPostDate)
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldAutoOpenPopover) {
|
||||
return
|
||||
}
|
||||
|
||||
openPopover()
|
||||
}, [shouldAutoOpenPopover])
|
||||
|
||||
// Persist the visible post so it doesn't reopen on the next visit.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return
|
||||
}
|
||||
|
||||
void markLatestBlogPostViewed()
|
||||
}, [isLastViewedDateFetched, lastViewedDateTimestamp, latestBlogPostKey, open])
|
||||
|
||||
if (!latestBlogPost) {
|
||||
return null
|
||||
}
|
||||
|
||||
const blogUrl = new URL(latestBlogPost.url, WEBSITE_URL).toString()
|
||||
const embedUrl = latestBlogPost.videoUrl ? buildBilibiliEmbedUrl(latestBlogPost.videoUrl) : null
|
||||
|
||||
return (
|
||||
<Popover
|
||||
key={latestBlogPostKey}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<PopoverTrigger
|
||||
render={(
|
||||
<SidebarMenuButton
|
||||
aria-label={i18n.t("options.whatsNew.title")}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<Icon icon="tabler:rss" />
|
||||
<span>{i18n.t("options.whatsNew.title")}</span>
|
||||
</PopoverTrigger>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
|
||||
<PopoverContent
|
||||
align="end"
|
||||
initialFocus={openType => openType === "keyboard"}
|
||||
side="top"
|
||||
sideOffset={8}
|
||||
className="w-[min(24rem,calc(100vw-2rem))] gap-4 p-3"
|
||||
>
|
||||
{embedUrl && (
|
||||
<div className="overflow-hidden rounded-md border bg-black">
|
||||
<iframe
|
||||
title={latestBlogPost.title}
|
||||
src={embedUrl}
|
||||
className="aspect-video w-full"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowFullScreen
|
||||
loading="eager"
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PopoverHeader className="gap-2">
|
||||
<PopoverTitle>
|
||||
<a
|
||||
href={blogUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group hover:underline"
|
||||
>
|
||||
<span className="min-w-0">
|
||||
{latestBlogPost.title}
|
||||
<Icon
|
||||
aria-hidden="true"
|
||||
icon="tabler:external-link"
|
||||
className="ml-1 inline size-[1em] align-[-0.125em] text-muted-foreground transition-colors group-hover:text-foreground"
|
||||
/>
|
||||
</span>
|
||||
</a>
|
||||
</PopoverTitle>
|
||||
<PopoverDescription>
|
||||
{latestBlogPost.description}
|
||||
</PopoverDescription>
|
||||
</PopoverHeader>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { semanticVersionSchema } from "@read-frog/definitions"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { z } from "zod"
|
||||
import { hasNewBlogPost } from "../blog"
|
||||
import { buildBilibiliEmbedUrl, extractBilibiliVideoId, hasNewBlogPost } from "../blog"
|
||||
|
||||
describe("hasNewBlogPost", () => {
|
||||
const baseDate = new Date("2025-01-01")
|
||||
@@ -147,3 +147,32 @@ describe("semanticVersionSchema", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("bilibili helpers", () => {
|
||||
const videoUrl = "https://www.bilibili.com/video/BV1JoAszwEfF/?vd_source=2c109749b890f71dc77161d6ddf1d5ce"
|
||||
|
||||
it("extracts the BVID from a bilibili watch URL", () => {
|
||||
expect(extractBilibiliVideoId(videoUrl)).toBe("BV1JoAszwEfF")
|
||||
})
|
||||
|
||||
it("builds an autoplay embed URL from a bilibili watch URL", () => {
|
||||
const embedUrl = buildBilibiliEmbedUrl(videoUrl)
|
||||
|
||||
expect(embedUrl).not.toBeNull()
|
||||
|
||||
const parsedUrl = new URL(embedUrl!)
|
||||
expect(parsedUrl.origin).toBe("https://player.bilibili.com")
|
||||
expect(parsedUrl.pathname).toBe("/player.html")
|
||||
expect(parsedUrl.searchParams.get("bvid")).toBe("BV1JoAszwEfF")
|
||||
expect(parsedUrl.searchParams.get("autoplay")).toBe("1")
|
||||
expect(parsedUrl.searchParams.get("muted")).toBe("1")
|
||||
})
|
||||
|
||||
it("returns null when the URL does not contain a bilibili video id", () => {
|
||||
expect(buildBilibiliEmbedUrl("https://readfrog.app/blog")).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null when the URL is not hosted on bilibili", () => {
|
||||
expect(extractBilibiliVideoId("https://readfrog.app/blog?bvid=BV1JoAszwEfF")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { PartialDeep } from "type-fest"
|
||||
import type { ProviderConfig } from "@/types/config/provider"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { DEFAULT_PROVIDER_CONFIG } from "@/utils/constants/providers"
|
||||
import { updateLLMProviderConfig, updateProviderConfig } from "../provider"
|
||||
|
||||
type OpenAIProviderConfig = Extract<ProviderConfig, { provider: "openai" }>
|
||||
|
||||
describe("provider config updates", () => {
|
||||
it("merges nested LLM model updates without changing untouched fields", () => {
|
||||
const result = updateLLMProviderConfig(DEFAULT_PROVIDER_CONFIG.openai, {
|
||||
model: {
|
||||
customModel: "gpt-5-custom",
|
||||
isCustomModel: true,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.model).toEqual({
|
||||
...DEFAULT_PROVIDER_CONFIG.openai.model,
|
||||
customModel: "gpt-5-custom",
|
||||
isCustomModel: true,
|
||||
})
|
||||
expect(result.provider).toBe("openai")
|
||||
})
|
||||
|
||||
it("merges provider option objects and preserves the rest of the config", () => {
|
||||
const result = updateProviderConfig(DEFAULT_PROVIDER_CONFIG.openai, {
|
||||
connectionOptions: {
|
||||
timeoutMs: 5000,
|
||||
},
|
||||
providerOptions: {
|
||||
reasoningEffort: "minimal",
|
||||
},
|
||||
}) as OpenAIProviderConfig
|
||||
|
||||
expect(result.connectionOptions).toEqual({ timeoutMs: 5000 })
|
||||
expect(result.providerOptions).toEqual({ reasoningEffort: "minimal" })
|
||||
expect(result.model).toEqual(DEFAULT_PROVIDER_CONFIG.openai.model)
|
||||
expect(result.provider).toBe("openai")
|
||||
})
|
||||
|
||||
it("rejects merged configs that no longer match the provider schema", () => {
|
||||
const invalidUpdates = {
|
||||
provider: "openai",
|
||||
} as PartialDeep<ProviderConfig>
|
||||
|
||||
expect(() => updateProviderConfig(DEFAULT_PROVIDER_CONFIG["google-translate"], invalidUpdates)).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -37,12 +37,15 @@ export const providerConfigAtom = atomFamily((id: string) =>
|
||||
),
|
||||
)
|
||||
|
||||
function mergeUnknown(base: unknown, updates: unknown): unknown {
|
||||
return (deepmerge as (base: unknown, updates: unknown) => unknown)(base, updates)
|
||||
}
|
||||
|
||||
export function updateLLMProviderConfig(
|
||||
config: LLMProviderConfig,
|
||||
updates: PartialDeep<LLMProviderConfig>,
|
||||
): LLMProviderConfig {
|
||||
// @ts-expect-error - Type instantiation too deep due to complex provider union types
|
||||
const result = deepmerge(config, updates)
|
||||
const result = mergeUnknown(config, updates) as LLMProviderConfig
|
||||
return llmProviderConfigItemSchema.parse(result)
|
||||
}
|
||||
|
||||
@@ -50,7 +53,6 @@ export function updateProviderConfig(
|
||||
config: ProviderConfig,
|
||||
updates: PartialDeep<ProviderConfig>,
|
||||
): ProviderConfig {
|
||||
// Keep the merge input shallow enough for TypeScript, then validate the result with Zod.
|
||||
const result = deepmerge(config, updates as Partial<ProviderConfig>) as ProviderConfig
|
||||
const result = mergeUnknown(config, updates) as ProviderConfig
|
||||
return providerConfigItemSchema.parse(result)
|
||||
}
|
||||
|
||||
@@ -6,17 +6,51 @@ import { sendMessage } from "./message"
|
||||
|
||||
const LAST_VIEWED_BLOG_DATE_KEY = "lastViewedBlogDate"
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
const BILIBILI_EMBED_HOSTNAME = "player.bilibili.com"
|
||||
const BILIBILI_HOSTNAME_PATTERN = /(?:^|\.)bilibili\.com$/i
|
||||
const BILIBILI_VIDEO_ID_PATTERN = /^BV[0-9A-Z]+$/i
|
||||
|
||||
/**
|
||||
* Zod schema for validating blog API response
|
||||
*/
|
||||
const blogApiResponseSchema = z.object({
|
||||
date: z.string(),
|
||||
function getBilibiliVideoIdFromParsedUrl(parsedUrl: URL): string | null {
|
||||
if (!BILIBILI_HOSTNAME_PATTERN.test(parsedUrl.hostname)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const bvid = parsedUrl.searchParams.get("bvid")
|
||||
if (BILIBILI_VIDEO_ID_PATTERN.test(bvid ?? "")) {
|
||||
return bvid
|
||||
}
|
||||
|
||||
const [, maybeVideoSegment, maybeVideoId] = parsedUrl.pathname.split("/")
|
||||
if (maybeVideoSegment !== "video") {
|
||||
return null
|
||||
}
|
||||
|
||||
return BILIBILI_VIDEO_ID_PATTERN.test(maybeVideoId ?? "") ? maybeVideoId ?? null : null
|
||||
}
|
||||
|
||||
const bilibiliVideoUrlSchema = z.url().superRefine((url, ctx) => {
|
||||
if (getBilibiliVideoIdFromParsedUrl(new URL(url))) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Must be a bilibili video URL with a valid BVID",
|
||||
})
|
||||
})
|
||||
|
||||
const latestBlogPostSchema = z.object({
|
||||
date: z.string().pipe(z.coerce.date()),
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
url: z.string(),
|
||||
extensionVersion: semanticVersionSchema.nullable().optional(),
|
||||
}).nullable()
|
||||
videoUrl: bilibiliVideoUrlSchema.optional(),
|
||||
extensionVersion: semanticVersionSchema.optional(),
|
||||
})
|
||||
|
||||
export type LatestBlogPost = z.output<typeof latestBlogPostSchema>
|
||||
|
||||
const blogApiResponseSchema = latestBlogPostSchema.nullable()
|
||||
|
||||
/**
|
||||
* Saves the last viewed blog date to Chrome storage
|
||||
@@ -50,6 +84,29 @@ export function hasNewBlogPost(
|
||||
return latestDate > latestViewedDate
|
||||
}
|
||||
|
||||
export function extractBilibiliVideoId(url: string): string | null {
|
||||
const result = bilibiliVideoUrlSchema.safeParse(url)
|
||||
if (!result.success) {
|
||||
return null
|
||||
}
|
||||
|
||||
return getBilibiliVideoIdFromParsedUrl(new URL(result.data))
|
||||
}
|
||||
|
||||
export function buildBilibiliEmbedUrl(url: string): string | null {
|
||||
const videoId = extractBilibiliVideoId(url)
|
||||
if (!videoId) {
|
||||
return null
|
||||
}
|
||||
|
||||
const embedUrl = new URL(`https://${BILIBILI_EMBED_HOSTNAME}/player.html`)
|
||||
embedUrl.searchParams.set("bvid", videoId)
|
||||
embedUrl.searchParams.set("autoplay", "1")
|
||||
embedUrl.searchParams.set("muted", "1")
|
||||
embedUrl.searchParams.set("danmaku", "0")
|
||||
return embedUrl.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the latest blog post from the Read Frog blog API.
|
||||
* Uses background fetch with optional 1-day cache.
|
||||
@@ -58,12 +115,20 @@ export function hasNewBlogPost(
|
||||
* @param locale - The locale to fetch the latest post for (default: 'en')
|
||||
* @param extensionVersion - The current extension version to filter compatible posts
|
||||
* @param useCache - Whether to use cache (default: true)
|
||||
* @returns Promise resolving to the latest blog post data (date, url, and extensionVersion), or null if no posts found
|
||||
* @returns Promise resolving to the latest blog post metadata, or null if no posts found
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const latestPost = await getLatestBlogDate('http://localhost:8888/api/blog/latest', 'en', '1.10.0')
|
||||
* console.log(latestPost) // { date: Date, url: '/blog/post-slug', extensionVersion: '1.11.0' }
|
||||
* console.log(latestPost)
|
||||
* // {
|
||||
* // date: Date,
|
||||
* // title: 'Spring update',
|
||||
* // description: 'New subtitle features shipped.',
|
||||
* // url: '/blog/post-slug',
|
||||
* // videoUrl: 'https://www.bilibili.com/video/BV...',
|
||||
* // extensionVersion: '1.11.0',
|
||||
* // }
|
||||
*
|
||||
* // Without cache
|
||||
* const freshPost = await getLatestBlogDate('http://localhost:8888/api/blog/latest', 'en', '1.10.0', false)
|
||||
@@ -74,7 +139,7 @@ export async function getLatestBlogDate(
|
||||
locale: string = "en",
|
||||
extensionVersion?: string,
|
||||
useCache: boolean = true,
|
||||
): Promise<{ date: Date, url: string, extensionVersion?: string | null } | null> {
|
||||
): Promise<LatestBlogPost | null> {
|
||||
try {
|
||||
const url = new URL(apiUrl)
|
||||
url.searchParams.set("locale", locale)
|
||||
@@ -98,25 +163,7 @@ export async function getLatestBlogDate(
|
||||
throw new Error(`Failed to fetch blog: ${response.status}`)
|
||||
}
|
||||
|
||||
// Parse and validate response with Zod
|
||||
const parsedBody = JSON.parse(response.body)
|
||||
const validationResult = blogApiResponseSchema.safeParse(parsedBody)
|
||||
|
||||
if (!validationResult.success) {
|
||||
throw new Error(`Invalid blog API response: ${validationResult.error.message}`)
|
||||
}
|
||||
|
||||
const latestPost = validationResult.data
|
||||
|
||||
if (!latestPost) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
date: new Date(latestPost.date),
|
||||
url: latestPost.url,
|
||||
extensionVersion: latestPost.extensionVersion ?? null,
|
||||
}
|
||||
return blogApiResponseSchema.parse(JSON.parse(response.body))
|
||||
}
|
||||
catch (error) {
|
||||
logger.error("Error fetching latest blog post:", error)
|
||||
|
||||