diff --git a/icons/mgc/line_cute_re.svg b/icons/mgc/line_cute_re.svg new file mode 100644 index 000000000..1878e76c1 --- /dev/null +++ b/icons/mgc/line_cute_re.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/renderer/src/atoms/index.ts b/src/renderer/src/atoms/index.ts index d87053269..ca6d44f23 100644 --- a/src/renderer/src/atoms/index.ts +++ b/src/renderer/src/atoms/index.ts @@ -1 +1,2 @@ export * from "./dom" +export * from "./route" diff --git a/src/renderer/src/atoms/route.ts b/src/renderer/src/atoms/route.ts new file mode 100644 index 000000000..868b49268 --- /dev/null +++ b/src/renderer/src/atoms/route.ts @@ -0,0 +1,38 @@ +/* eslint-disable unicorn/no-unreadable-array-destructuring */ +import { createAtomHooks } from "@renderer/lib/jotai" +import { atom, useAtomValue } from "jotai" +import { selectAtom } from "jotai/utils" +import { useMemo } from "react" +import type { NavigateFunction, Params } from "react-router-dom" + +interface RouteAtom { + params: Readonly> + searchParams: URLSearchParams +} + +export const [routeAtom, , , , getReadonlyRoute, setRoute] = createAtomHooks( + atom({ + params: {}, + searchParams: new URLSearchParams(), + }), +) + +const noop = [] +export const useReadonlyRouteSelector = ( + selector: (route: RouteAtom) => T, + deps: any[] = noop, +): T => + useAtomValue( + useMemo(() => selectAtom(routeAtom, (route) => selector(route)), deps), + ) + +// VITE HMR will create new router instance, but RouterProvider always stable + +const [, , , , navigate, setNavigate] = createAtomHooks( + atom<{ fn: NavigateFunction | null }>({ fn() {} }), +) +const getStableRouterNavigate = () => navigate().fn +export { + getStableRouterNavigate, + setNavigate, +} diff --git a/src/renderer/src/components/user-button.tsx b/src/renderer/src/components/user-button.tsx index 1e6b5ebe1..dffbd1400 100644 --- a/src/renderer/src/components/user-button.tsx +++ b/src/renderer/src/components/user-button.tsx @@ -10,6 +10,7 @@ import { cn } from "@renderer/lib/utils" import { useSession } from "@renderer/queries/auth" import { m } from "framer-motion" import type { FC } from "react" +import { memo } from "react" import { Link } from "react-router-dom" import { FollowIcon } from "./icons/follow" @@ -102,7 +103,7 @@ export const LoginButton: FC = (props) => { ) } -export const ProfileButton: FC = (props) => { +export const ProfileButton: FC = memo((props) => { const { status } = useSession() if (status !== "authenticated") { @@ -115,7 +116,8 @@ export const ProfileButton: FC = (props) => { ) -} +}) +ProfileButton.displayName = "ProfileButton" export function UserButton({ className, diff --git a/src/renderer/src/hooks/biz/useAsRead.ts b/src/renderer/src/hooks/biz/useAsRead.ts index bdc549b82..0e684331b 100644 --- a/src/renderer/src/hooks/biz/useAsRead.ts +++ b/src/renderer/src/hooks/biz/useAsRead.ts @@ -1,10 +1,11 @@ import { FEED_COLLECTION_LIST, levels } from "@renderer/lib/constants" import type { EntryModel } from "@renderer/models" -import { useFeedStore } from "@renderer/store" + +import { useRouteParamsSelector } from "./useRouteParams" export function useAsRead(entry?: EntryModel) { - const activeList = useFeedStore((state) => state.activeList) - - if (!entry) return false - return entry.read && !(activeList?.level === levels.folder && activeList?.id === FEED_COLLECTION_LIST) + return useRouteParamsSelector(({ feedId, level }) => { + if (!entry) return false + return entry.read && !(level === levels.folder && feedId === FEED_COLLECTION_LIST) + }, [entry?.read]) } diff --git a/src/renderer/src/hooks/biz/useEntryActions.tsx b/src/renderer/src/hooks/biz/useEntryActions.tsx index 2d8ffec57..1aab7579e 100644 --- a/src/renderer/src/hooks/biz/useEntryActions.tsx +++ b/src/renderer/src/hooks/biz/useEntryActions.tsx @@ -1,7 +1,7 @@ import { apiClient } from "@renderer/lib/api-fetch" import { client } from "@renderer/lib/client" import type { EntryModel } from "@renderer/models" -import { entryActions } from "@renderer/store/entry/entry" +import { entryActions } from "@renderer/store" import { useMutation, useQuery } from "@tanstack/react-query" import type { FetchError } from "ofetch" import { ofetch } from "ofetch" diff --git a/src/renderer/src/hooks/biz/useNavigateEntry.ts b/src/renderer/src/hooks/biz/useNavigateEntry.ts new file mode 100644 index 000000000..d4f56aeb6 --- /dev/null +++ b/src/renderer/src/hooks/biz/useNavigateEntry.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/no-unused-expressions */ +import { getReadonlyRoute, getStableRouterNavigate } from "@renderer/atoms" +import { ROUTE_FEED_PENDING } from "@renderer/lib/constants" +import type { FeedViewType } from "@renderer/lib/enum" +import { isUndefined } from "lodash-es" +import { useCallback } from "react" + +type NavigateEntryOptions = Partial<{ + feedId: string | null + entryId: string | null + view: FeedViewType + level: string | null + + category: string | null +}> +/** + * @description a hook to navigate to `feedId`, `entryId`, add search for `view`, `level` + */ +export const useNavigateEntry = () => useCallback((options: NavigateEntryOptions) => { + const { entryId, feedId, level, view, category } = options || {} + const { params, searchParams } = getReadonlyRoute() + let finalFeedId = feedId || params.feedId || ROUTE_FEED_PENDING + + if ("feedId" in options && feedId === null) { + finalFeedId = ROUTE_FEED_PENDING + } + + const nextSearchParams = new URLSearchParams(searchParams) + + !isUndefined(view) && nextSearchParams.set("view", view.toString()) + level && nextSearchParams.set("level", level.toString()) + + if ("category" in options) { + if (!category) { + nextSearchParams.delete("category") + } else { + nextSearchParams.set("category", category.toString()) + } + } + + return getStableRouterNavigate()?.( + `/feeds/${finalFeedId}/${ + entryId || ROUTE_FEED_PENDING + }?${nextSearchParams.toString()}`, + ) +}, []) diff --git a/src/renderer/src/hooks/biz/useRouteParams.ts b/src/renderer/src/hooks/biz/useRouteParams.ts new file mode 100644 index 000000000..e5b3b0b7c --- /dev/null +++ b/src/renderer/src/hooks/biz/useRouteParams.ts @@ -0,0 +1,85 @@ +import { useReadonlyRouteSelector } from "@renderer/atoms" +import { FeedViewType } from "@renderer/lib/enum" +import { useParams, useSearchParams } from "react-router-dom" +// '0', '1', '2', '3', '4', '5', +const FeedViewTypeValues = (() => { + const values = Object.values(FeedViewType) + return values.slice(values.length / 2).map((v) => v.toString()) +})() +export const useRouteView = () => { + const [search] = useSearchParams() + const view = search.get("view") + + return ( + (view && FeedViewTypeValues.includes(view) ? + +view : + FeedViewType.Articles) || FeedViewType.Articles + ) +} + +export const useRouteEntryId = () => { + const { entryId } = useParams() + return entryId +} + +export const useRouteFeedId = () => { + const { feedId } = useParams() + return feedId +} + +export const useRouteParms = () => { + const params = useParams() + const [search] = useSearchParams() + const view = useRouteView() + + let feedId: string | number = params.feedId! + + // If feedId is a number, it's a FeedViewType + if (feedId && FeedViewTypeValues.includes(feedId as string)) { + feedId = Number.parseInt(feedId as string) + } + + return { + view, + entryId: params.entryId || undefined, + feedId: params.feedId || undefined, + level: search.get("level") || undefined, + category: search.get("category") || undefined, + } +} +const noop = [] as any[] +export const useRouteParamsSelector = ( + selector: (params: { + entryId: string | undefined + feedId: string | undefined + level: string | undefined + category: string | undefined + view: FeedViewType + }) => T, + deps = noop, +): T => + useReadonlyRouteSelector((route) => { + const { searchParams, params } = route + + let feedId: string | number = params.feedId! + + // If feedId is a number, it's a FeedViewType + if (feedId && FeedViewTypeValues.includes(feedId as string)) { + feedId = Number.parseInt(feedId as string) + } + + const view = searchParams.get("view") + + const finalView = + (view && FeedViewTypeValues.includes(view) ? + +view : + FeedViewType.Articles) || FeedViewType.Articles + + return selector({ + entryId: params.entryId || undefined, + feedId: params.feedId || undefined, + level: searchParams.get("level") || undefined, + category: searchParams.get("category") || undefined, + view: finalView, + }) + }, deps) diff --git a/src/renderer/src/lib/api-fetch.ts b/src/renderer/src/lib/api-fetch.ts index c4f487f91..9e88c261a 100644 --- a/src/renderer/src/lib/api-fetch.ts +++ b/src/renderer/src/lib/api-fetch.ts @@ -7,6 +7,7 @@ import { ofetch } from "ofetch" export abstract class RequestError extends Error { name = "RequestError" } +const csrfToken = await getCsrfToken() export const apiFetch = ofetch.create({ baseURL: import.meta.env.VITE_API_URL, @@ -14,8 +15,6 @@ export const apiFetch = ofetch.create({ retry: false, onRequest: async ({ options }) => { if (options.method && options.method.toLowerCase() !== "get") { - const csrfToken = await getCsrfToken() - if (typeof options.body === "string") { options.body = JSON.parse(options.body) } diff --git a/src/renderer/src/lib/constants.tsx b/src/renderer/src/lib/constants.tsx index 1374b2e0f..6f806c88c 100644 --- a/src/renderer/src/lib/constants.tsx +++ b/src/renderer/src/lib/constants.tsx @@ -74,9 +74,12 @@ export const settingTabs = [ }, ] -//// +/// App export const APP_NAME = "Follow" /// Feed export const FEED_COLLECTION_LIST = "collections" /// Local storage keys export const QUERY_PERSIST_KEY = buildStorageNS("REACT_QUERY_OFFLINE_CACHE") + +/// Route Keys +export const ROUTE_FEED_PENDING = "pending" diff --git a/src/renderer/src/lib/route-builder.test.ts b/src/renderer/src/lib/route-builder.test.ts index b3c61a373..7af1c1885 100644 --- a/src/renderer/src/lib/route-builder.test.ts +++ b/src/renderer/src/lib/route-builder.test.ts @@ -10,8 +10,8 @@ describe("test route builder", () => { "./pages/(external)/layout.tsx": fakePromise, "./pages/(external)/(with-layout)/index.tsx": fakePromise, "./pages/(external)/(with-layout)/layout.tsx": fakePromise, - "./pages/(external)/(with-layout)/feed/[:id]/index.tsx": fakePromise, - "./pages/(external)/(with-layout)/feed/[:id]/layout.tsx": fakePromise, + "./pages/(external)/(with-layout)/feed/[id]/index.tsx": fakePromise, + "./pages/(external)/(with-layout)/feed/[id]/layout.tsx": fakePromise, "./pages/(main)/layout.tsx": fakePromise, "./pages/(main)/(context)/layout.tsx": fakePromise, @@ -118,7 +118,7 @@ describe("test route builder", () => { "children": [ { "handle": { - "fs": "./pages/(external)/(with-layout)/feed/[:id]/index/", + "fs": "./pages/(external)/(with-layout)/feed/[id]/index/", "fullPath": "/feed/:id/", }, "lazy": [Function], @@ -126,7 +126,7 @@ describe("test route builder", () => { }, ], "handle": { - "fs": "./pages/(external)/(with-layout)/feed/[:id]/layout", + "fs": "./pages/(external)/(with-layout)/feed/[id]/layout", "fullPath": "/feed/:id", }, "lazy": [Function], @@ -134,7 +134,7 @@ describe("test route builder", () => { }, ], "handle": { - "fs": "./pages/(external)/(with-layout)/feed/[:id]/:id", + "fs": "./pages/(external)/(with-layout)/feed/[id]/:id", "fullPath": "/feed/:id", }, "path": ":id", diff --git a/src/renderer/src/lib/route-builder.ts b/src/renderer/src/lib/route-builder.ts index 659893e14..4eac6eb91 100644 --- a/src/renderer/src/lib/route-builder.ts +++ b/src/renderer/src/lib/route-builder.ts @@ -189,8 +189,8 @@ const normalizePathKey = (key: string) => { return "" } - if (key.startsWith("[:") && key.endsWith("]")) { - return `:${key.slice(2, -1)}` + if (key.startsWith("[") && key.endsWith("]")) { + return `:${key.slice(1, -1)}` } return key } diff --git a/src/renderer/src/models/types.ts b/src/renderer/src/models/types.ts index 7845407af..93fad4d68 100644 --- a/src/renderer/src/models/types.ts +++ b/src/renderer/src/models/types.ts @@ -51,9 +51,10 @@ export type DiscoverResponse = Array< >[number] > -export type ActionsResponse = Exclude["data"], undefined>["rules"] +export type ActionsResponse = Exclude< + ExtractBizResponse["data"], + undefined +>["rules"] export type ListResponse = { code: number @@ -67,7 +68,7 @@ export type DataResponse = { data?: T } -export type ActiveEntryId = string | null +export type ActiveEntryId = Nullable export type SubscriptionModel = SubscriptionResponse[number] @@ -78,4 +79,6 @@ export type FeedListModel = { }[] } -export type SupportedLanguages = Parameters[0]["query"]["language"] +export type SupportedLanguages = Parameters< + typeof apiClient.ai.translation.$get +>[0]["query"]["language"] diff --git a/src/renderer/src/modules/entry-column/index.tsx b/src/renderer/src/modules/entry-column/index.tsx index e3e6ea801..dc4248142 100644 --- a/src/renderer/src/modules/entry-column/index.tsx +++ b/src/renderer/src/modules/entry-column/index.tsx @@ -1,7 +1,4 @@ -import { - ActionButton, - StyledButton, -} from "@renderer/components/ui/button" +import { ActionButton, StyledButton } from "@renderer/components/ui/button" import { Popover, PopoverClose, @@ -9,6 +6,11 @@ import { PopoverTrigger, } from "@renderer/components/ui/popover" import { useRead, useRefValue } from "@renderer/hooks" +import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry" +import { + useRouteEntryId, + useRouteParms, +} from "@renderer/hooks/biz/useRouteParams" import { apiClient } from "@renderer/lib/api-fetch" import { views } from "@renderer/lib/constants" import { buildStorageNS } from "@renderer/lib/ns" @@ -16,12 +18,10 @@ import { shortcuts } from "@renderer/lib/shortcuts" import { getEntriesParams } from "@renderer/lib/utils" import { useEntries } from "@renderer/queries/entries" import { - feedActions, - getCurrentEntryId, + entryActions, subscriptionActions, - useFeedStore, + useFeedHeaderTitle, } from "@renderer/store" -import { entryActions } from "@renderer/store/entry/entry" import { useEntry, useEntryIdsByFeedIdOrView, @@ -44,7 +44,6 @@ import { useHotkeys } from "react-hotkeys-hook" import type { ListRange, VirtuosoHandle, VirtuosoProps } from "react-virtuoso" import { Virtuoso, VirtuosoGrid } from "react-virtuoso" import { useEventCallback } from "usehooks-ts" -import { useShallow } from "zustand/react/shallow" import { EmptyIcon } from "../../components/icons/empty" import { LoadingCircle } from "../../components/ui/loading" @@ -60,13 +59,9 @@ const unreadOnlyAtom = atomWithStorage( ) export function EntryColumn() { - const { activeList, activeEntryId } = useFeedStore((state) => ({ - activeList: state.activeList, - activeEntryId: state.activeEntryId, - })) const entries = useEntriesByView() const { entriesIds, isFetchingNextPage } = entries - + const { entryId: activeEntryId, view, feedId } = useRouteParms() const activeEntry = useEntry(activeEntryId) const markReadMutation = useRead() useEffect(() => { @@ -131,23 +126,25 @@ export function EntryColumn() { (_, entryId: string) => { if (!entryId) return null - return ( - - ) + return }, - [activeList?.view], + [view], ), } + const navigate = useNavigateEntry() return (
feedActions.setActiveEntry(null)} + onClick={() => + navigate({ + entryId: null, + })} data-total-count={virtuosoOptions.totalCount} > {virtuosoOptions.totalCount === 0 ? ( - ) : activeList?.view && views[activeList.view].gridMode ? + ) : view && views[view].gridMode ? ( { - const activeList = useFeedStore(useShallow((state) => state.activeList)) + const activeList = useRouteParms() const unreadOnly = useAtomValue(unreadOnlyAtom) const query = useEntries({ level: activeList?.level, - id: activeList.id, + id: activeList.feedId, view: activeList?.view, ...(unreadOnly === true && { read: false }), }) - const entries = useEntryIdsByFeedIdOrView(activeList.id, { + const entries = useEntryIdsByFeedIdOrView(activeList.feedId!, { unread: unreadOnly, }) @@ -194,7 +191,7 @@ const useEntriesByView = () => { useEffect(() => { prevEntries.current = [] - }, [activeList.id]) + }, [activeList.feedId]) const localEntries = useMemo(() => { if (!unreadOnly) { prevEntries.current = [] @@ -242,31 +239,31 @@ const useEntriesByView = () => { const ListHeader: FC<{ totalCount: number }> = ({ totalCount }) => { - const activeList = useFeedStore(useShallow((state) => state.activeList)) + const routerParams = useRouteParms() const [unreadOnly, setUnreadOnly] = useAtom(unreadOnlyAtom) const [markPopoverOpen, setMarkPopoverOpen] = useState(false) const handleMarkAllAsRead = useCallback(async () => { - if (!activeList) return + if (!routerParams) return await apiClient.reads.all.$post({ json: { ...getEntriesParams({ - level: activeList?.level, - id: activeList?.id, - view: activeList?.view, + level: routerParams?.level, + id: routerParams?.feedId, + view: routerParams?.view, }), }, }) - if (typeof activeList.id === "number") { - subscriptionActions.markReadByView(activeList.view) + if (typeof routerParams.feedId === "number") { + subscriptionActions.markReadByView(routerParams.view) } else { - activeList.id.split(",").forEach((feedId) => { + routerParams.feedId?.split(",").forEach((feedId) => { entryActions.markReadByFeedId(feedId) }) } setMarkPopoverOpen(false) - }, [activeList]) + }, [routerParams]) useHotkeys(shortcuts.entries.markAllAsRead.key, () => { setMarkPopoverOpen(true) @@ -276,6 +273,7 @@ const ListHeader: FC<{ setUnreadOnly((prev) => !prev) }, { scopes: ["home"] }) + const headerTitle = useFeedHeaderTitle() return (
@@ -312,7 +310,7 @@ const ListHeader: FC<{
-
{activeList?.name}
+
{headerTitle}
{totalCount || 0} {" "} @@ -362,10 +360,14 @@ const EntryList: FC> = ({ const dataRef = useRefValue(virtuosoOptions.data!) + const currentEntryIdRef = useRefValue(useRouteEntryId()) + + const navigate = useNavigateEntry() + useHotkeys(shortcuts.entries.next.key, () => { const data = dataRef.current const currentActiveEntryIndex = data.indexOf( - getCurrentEntryId() || "", + currentEntryIdRef.current || "", ) const nextIndex = Math.min(currentActiveEntryIndex + 1, data.length - 1) @@ -374,13 +376,16 @@ const EntryList: FC> = ({ index: nextIndex, }) const nextId = data![nextIndex] - feedActions.setActiveEntry(nextId) + + navigate({ + entryId: nextId, + }) }, { scopes: ["home"] }) useHotkeys(shortcuts.entries.previous.key, () => { const data = dataRef.current const currentActiveEntryIndex = data.indexOf( - getCurrentEntryId() || "", + currentEntryIdRef.current || "", ) const nextIndex = currentActiveEntryIndex === -1 ? data.length - 1 : Math.max(0, currentActiveEntryIndex - 1) @@ -389,7 +394,10 @@ const EntryList: FC> = ({ index: nextIndex, }) const nextId = data![nextIndex] - feedActions.setActiveEntry(nextId) + + navigate({ + entryId: nextId, + }) }, { scopes: ["home"] }) return ( diff --git a/src/renderer/src/modules/entry-column/item.tsx b/src/renderer/src/modules/entry-column/item.tsx index 04776f218..51edd67e1 100644 --- a/src/renderer/src/modules/entry-column/item.tsx +++ b/src/renderer/src/modules/entry-column/item.tsx @@ -1,11 +1,12 @@ import { useAsRead, useBizQuery, useEntryActions } from "@renderer/hooks" +import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry" +import { useRouteParamsSelector } from "@renderer/hooks/biz/useRouteParams" import { views } from "@renderer/lib/constants" import { FeedViewType } from "@renderer/lib/enum" import { showNativeMenu } from "@renderer/lib/native-menu" import { cn } from "@renderer/lib/utils" import type { EntryModel } from "@renderer/models" import { Queries } from "@renderer/queries" -import { feedActions, useFeedStore } from "@renderer/store" import { useEntry } from "@renderer/store/entry/hooks" import type { FC } from "react" import { memo, useCallback } from "react" @@ -40,7 +41,7 @@ function EntryItemImpl({ entry, view }: { entry: EntryModel, view?: number }) { }, ) - const activeEntry = useFeedStore((state) => state.activeEntryId) + const isActive = useRouteParamsSelector(({ entryId }) => entryId === entry.entries.id) const asRead = useAsRead(entry) @@ -75,14 +76,17 @@ function EntryItemImpl({ entry, view }: { entry: EntryModel, view?: number }) { Item = ArticleItem } } - const handleKeyDown: React.KeyboardEventHandler = - useCallback(() => {}, []) + + const navigate = useNavigateEntry() const handleClick: React.MouseEventHandler = useCallback( (e) => { e.stopPropagation() - feedActions.setActiveEntry(entry.entries.id) + + navigate({ + entryId: entry.entries.id, + }) }, - [entry.entries.id], + [entry.entries.id, navigate], ) const handleDoubleClick: React.MouseEventHandler = useCallback( @@ -116,11 +120,10 @@ function EntryItemImpl({ entry, view }: { entry: EntryModel, view?: number }) { className={cn( "rounded-md bg-theme-background transition-colors", !views[view || 0].wideMode && - activeEntry === entry.entries.id && + isActive && "bg-theme-item-active", asRead ? "text-zinc-500/90" : "text-zinc-900 dark:text-white/90", )} - onKeyDown={handleKeyDown} onClick={handleClick} onDoubleClick={handleDoubleClick} onContextMenu={handleContextMenu} diff --git a/src/renderer/src/modules/entry-content/index.tsx b/src/renderer/src/modules/entry-content/index.tsx index 63eaba30a..74bc9cfc6 100644 --- a/src/renderer/src/modules/entry-content/index.tsx +++ b/src/renderer/src/modules/entry-content/index.tsx @@ -8,7 +8,7 @@ import { WrappedElementProvider, } from "@renderer/providers/wrapped-element-provider" import { Queries } from "@renderer/queries" -import { useEntry, useFeedStore } from "@renderer/store" +import { useEntry, useFeedHeaderTitle } from "@renderer/store" import { m } from "framer-motion" import { useEffect, useState } from "react" @@ -17,10 +17,9 @@ import { EntryTranslation } from "../entry-column/translation" import { setEntryTitleMeta } from "./atoms" import { EntryHeader } from "./header" -export const EntryContent = ({ entry }: { entry: ActiveEntryId }) => { - const activeList = useFeedStore((state) => state.activeList) - - if (!entry) { +export const EntryContent = ({ entryId }: { entryId: ActiveEntryId }) => { + const title = useFeedHeaderTitle() + if (!entryId) { return ( { animate={{ opacity: 1, y: 0 }} > - {activeList?.name} + {title} ) } - return + return } function EntryContentRender({ entryId }: { entryId: string }) { @@ -169,7 +168,9 @@ const TitleMetaHandler: Component<{ } = useEntry(entryId)! useEffect(() => { - if (!isAtTop && entryTitle && feedTitle) { setEntryTitleMeta({ title: entryTitle, description: feedTitle }) } + if (!isAtTop && entryTitle && feedTitle) { + setEntryTitleMeta({ title: entryTitle, description: feedTitle }) + } return () => { setEntryTitleMeta(null) } diff --git a/src/renderer/src/modules/feed-column/category.tsx b/src/renderer/src/modules/feed-column/category.tsx index 28f0babb7..e2ef0dabc 100644 --- a/src/renderer/src/modules/feed-column/category.tsx +++ b/src/renderer/src/modules/feed-column/category.tsx @@ -2,28 +2,23 @@ import { Collapsible, CollapsibleTrigger, } from "@renderer/components/ui/collapsible" +import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry" +import { useRouteParamsSelector } from "@renderer/hooks/biz/useRouteParams" import { levels } from "@renderer/lib/constants" +import { stopPropagation } from "@renderer/lib/dom" import { showNativeMenu } from "@renderer/lib/native-menu" import { cn } from "@renderer/lib/utils" import type { FeedListModel } from "@renderer/models" -import { - feedActions, - useFeedActiveList, - useUnreadStore, -} from "@renderer/store" +import { useUnreadStore } from "@renderer/store" import { AnimatePresence, m } from "framer-motion" -import { useEffect, useState } from "react" +import { memo, useEffect, useState } from "react" import { useModalStack } from "../../components/ui/modal/stacked/hooks" import { CategoryRemoveDialogContent } from "./category-remove-dialog" -import { - CategoryRenameContent, -} from "./category-rename-dialog" +import { CategoryRenameContent } from "./category-rename-dialog" import { FeedItem } from "./item" -const { setActiveList } = feedActions - -export function FeedCategory({ +function FeedCategoryImpl({ data, view, expansion, @@ -32,8 +27,6 @@ export function FeedCategory({ view?: number expansion: boolean }) { - const activeList = useFeedActiveList() - const [open, setOpen] = useState(!data.name) const feedIdList = data.list.map((feed) => feed.feedId) @@ -44,13 +37,17 @@ export function FeedCategory({ } }, [expansion]) + const navigate = useNavigateEntry() + const setCategoryActive = () => { if (view !== undefined) { - setActiveList({ + navigate({ + entryId: null, + // TODO joint feedId is too long, need to be optimized + feedId: data.list.map((feed) => feed.feedId).join(","), level: levels.folder, - id: data.list.map((feed) => feed.feedId).join(","), - name: data.name, view, + category: data.name, }) } } @@ -64,6 +61,9 @@ export function FeedCategory({ (a, b) => (state.data[b.feedId] || 0) - (state.data[a.feedId] || 0), ), ) + + const isActive = useRouteParamsSelector((routerParams) => routerParams?.level === levels.folder && + routerParams.feedId === data.list.map((feed) => feed.feedId).join(",")) const { present } = useModalStack() return ( { e.stopPropagation() @@ -123,15 +121,14 @@ export function FeedCategory({ >
- {!setActiveList && {data.name}} - {!!setActiveList && {data.name}} + {data.name}
{!!unread && (
{unread}
@@ -158,7 +155,7 @@ export function FeedCategory({ {sortByUnreadFeedList.map((feed) => ( @@ -169,3 +166,5 @@ export function FeedCategory({
) } + +export const FeedCategory = memo(FeedCategoryImpl) diff --git a/src/renderer/src/modules/feed-column/index.tsx b/src/renderer/src/modules/feed-column/index.tsx index 311c4bbc9..c65296b52 100644 --- a/src/renderer/src/modules/feed-column/index.tsx +++ b/src/renderer/src/modules/feed-column/index.tsx @@ -1,14 +1,14 @@ import { Logo } from "@renderer/components/icons/logo" import { ActionButton } from "@renderer/components/ui/button" import { ProfileButton } from "@renderer/components/user-button" +import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry" import { APP_NAME, levels, views } from "@renderer/lib/constants" import { stopPropagation } from "@renderer/lib/dom" import { clamp, cn } from "@renderer/lib/utils" -import { feedActions } from "@renderer/store" import { useWheel } from "@use-gesture/react" import { m, useSpring } from "framer-motion" import { Lethargy } from "lethargy" -import { useEffect, useRef, useState } from "react" +import { useCallback, useEffect, useRef, useState } from "react" import { Link } from "react-router-dom" import { Vibrancy } from "../../components/ui/background" @@ -17,7 +17,6 @@ import { FeedList } from "./list" const lethargy = new Lethargy() export function FeedColumn() { - const { setActiveList } = feedActions const carouselRef = useRef(null) const [active, setActive] = useState(0) @@ -56,16 +55,20 @@ export function FeedColumn() { const normalStyle = !window.electron || window.electron.process.platform !== "darwin" + const navigate = useNavigateEntry() + + const navigateBackHome = useCallback(() => { + navigate({ + feedId: null, + entryId: null, + view: active, + level: levels.view, + }) + }, [active, navigate]) return ( - setActiveList({ - level: levels.view, - id: active, - name: views[active].name, - view: active, - })} + onClick={navigateBackHome} >
{ e.stopPropagation() - setActiveList({ - level: levels.view, - id: active, - name: views[active].name, - view: active, - }) + + navigateBackHome() }} > @@ -116,11 +115,12 @@ export function FeedColumn() { )} onClick={(e) => { setActive(index) - setActiveList?.({ - level: "view", - id: index, - name: views[index].name, + + navigate({ + feedId: null, + entryId: null, view: index, + level: levels.view, }) e.stopPropagation() }} diff --git a/src/renderer/src/modules/feed-column/item.tsx b/src/renderer/src/modules/feed-column/item.tsx index 5e1392473..7038d55c1 100644 --- a/src/renderer/src/modules/feed-column/item.tsx +++ b/src/renderer/src/modules/feed-column/item.tsx @@ -8,54 +8,56 @@ import { TooltipProvider, TooltipTrigger, } from "@renderer/components/ui/tooltip" +import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry" +import { useRouteParamsSelector } from "@renderer/hooks/biz/useRouteParams" import { apiClient } from "@renderer/lib/api-fetch" import { levels } from "@renderer/lib/constants" import dayjs from "@renderer/lib/dayjs" import { showNativeMenu } from "@renderer/lib/native-menu" import { cn } from "@renderer/lib/utils" -import type { SubscriptionResponse } from "@renderer/models" import { Queries } from "@renderer/queries" -import { - feedActions, - useFeedActiveList, - useUnreadStore, -} from "@renderer/store" +import type { SubscriptionPlainModel } from "@renderer/store" +import { getFeedById, useFeedById, useUnreadStore } from "@renderer/store" import { useMutation } from "@tanstack/react-query" +import { memo, useCallback } from "react" import { toast } from "sonner" import { FeedForm } from "../discover/feed-form" -export function FeedItem({ - feed, +type FeedItemData = SubscriptionPlainModel +const FeedItemImpl = ({ + subscription, view, className, }: { - feed: SubscriptionResponse[number] + subscription: FeedItemData view?: number className?: string -}) { - const activeList = useFeedActiveList() - const { setActiveList } = feedActions - - const setFeedActive = (feed: SubscriptionResponse[number]) => { - if (view === undefined) return - - setActiveList({ - level: levels.feed, - id: feed.feedId, - name: feed.feeds.title || "", - view, - }) - // focus to main container in order to let keyboard can navigate entry items by arrow keys - requestAnimationFrame(() => { - requestAnimationFrame(() => { - getMainContainerElement()?.focus() +}) => { + const navigate = useNavigateEntry() + const handleNavigate: React.MouseEventHandler = useCallback( + (e) => { + e.stopPropagation() + if (view === undefined) return + navigate({ + feedId: subscription.feedId, + entryId: null, + view, + level: levels.feed, + category: null, }) - }) - } + // focus to main container in order to let keyboard can navigate entry items by arrow keys + requestAnimationFrame(() => { + requestAnimationFrame(() => { + getMainContainerElement()?.focus() + }) + }) + }, + [subscription.feedId, navigate, view], + ) const deleteMutation = useMutation({ - mutationFn: async (feed: SubscriptionResponse[number]) => + mutationFn: async (feed: SubscriptionPlainModel) => apiClient.subscriptions.$delete({ json: { feedId: feed.feedId, @@ -65,13 +67,17 @@ export function FeedItem({ onSuccess: (_, variables) => { Queries.subscription.byView(variables.view).invalidate() + const feed = getFeedById(variables.feedId) + + if (!feed) return toast( <> Feed {" "} - {variables.feeds.title} + {feed.title} {" "} - has been unfollowed. + has been + unfollowed. , { duration: 3000, @@ -80,14 +86,14 @@ export function FeedItem({ onClick: async () => { await apiClient.subscriptions.$post({ json: { - url: variables.feeds.url, + url: feed.url, view: variables.view, category: variables.category, isPrivate: variables.isPrivate, }, }) - Queries.subscription.byView(feed.view).invalidate() + Queries.subscription.byView(variables.view).invalidate() }, }, }, @@ -95,24 +101,33 @@ export function FeedItem({ }, }) - const feedUnread = useUnreadStore((state) => state.data[feed.feedId] || 0) + const feedUnread = useUnreadStore( + (state) => state.data[subscription.feedId] || 0, + ) const { present } = useModalStack() + + const isActive = useRouteParamsSelector( + (routerParams) => + routerParams?.level === levels.feed && + routerParams.feedId === subscription.feedId, + ) + + const feed = useFeedById(subscription.feedId) + + if (!feed) return null return (
{ - e.stopPropagation() - setFeedActive(feed) - }} + onClick={handleNavigate} onDoubleClick={() => { window.open( - `${import.meta.env.VITE_WEB_URL}/feed/${feed.feedId}?view=${view}`, + `${import.meta.env.VITE_WEB_URL}/feed/${ + subscription.feedId + }?view=${view}`, "_blank", ) }} @@ -126,14 +141,20 @@ export function FeedItem({ click: () => { present({ title: "Edit Feed", - content: ({ dismiss }) => , + content: ({ dismiss }) => ( + + ), }) }, }, { type: "text", label: "Unfollow", - click: () => deleteMutation.mutate(feed), + click: () => deleteMutation.mutate(subscription), }, { type: "separator", @@ -144,7 +165,7 @@ export function FeedItem({ click: () => window.open( `${import.meta.env.VITE_WEB_URL}/feed/${ - feed.feedId + subscription.feedId }?view=${view}`, "_blank", ), @@ -152,8 +173,12 @@ export function FeedItem({ { type: "text", label: "Open Site in Browser", - click: () => - feed.feeds.siteUrl && window.open(feed.feeds.siteUrl, "_blank"), + click: () => { + const feed = getFeedById(subscription.feedId) + if (feed) { + feed.siteUrl && window.open(feed.siteUrl, "_blank") + } + }, }, ], e, @@ -163,12 +188,12 @@ export function FeedItem({
- -
{feed.feeds.title}
- {feed.feeds.errorAt && ( + +
{feed.title}
+ {feed.errorAt && ( @@ -180,7 +205,7 @@ export function FeedItem({ {" "} {dayjs .duration( - dayjs(feed.feeds.errorAt).diff(dayjs(), "minute"), + dayjs(feed.errorAt).diff(dayjs(), "minute"), "minute", ) .humanize(true)} @@ -189,7 +214,7 @@ export function FeedItem({ )} - {feed.isPrivate && ( + {subscription.isPrivate && ( @@ -210,3 +235,5 @@ export function FeedItem({
) } + +export const FeedItem = memo(FeedItemImpl) diff --git a/src/renderer/src/modules/feed-column/list.tsx b/src/renderer/src/modules/feed-column/list.tsx index 69fce739f..3c8672093 100644 --- a/src/renderer/src/modules/feed-column/list.tsx +++ b/src/renderer/src/modules/feed-column/list.tsx @@ -1,13 +1,17 @@ import { useBizQuery } from "@renderer/hooks" +import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry" +import { useRouteFeedId } from "@renderer/hooks/biz/useRouteParams" import { FEED_COLLECTION_LIST, levels, views } from "@renderer/lib/constants" import { stopPropagation } from "@renderer/lib/dom" import type { FeedViewType } from "@renderer/lib/enum" import { cn } from "@renderer/lib/utils" -import type { FeedListModel, SubscriptionResponse } from "@renderer/models" +import type { + FeedListModel, +} from "@renderer/models" import { Queries } from "@renderer/queries" +import type { SubscriptionPlainModel } from "@renderer/store" import { - feedActions, - useFeedActiveList, + getFeedById, useSubscriptionByView, useUnreadStore, } from "@renderer/store" @@ -30,7 +34,7 @@ const useData = (view: FeedViewType) => { list: Record< string, { - list: SubscriptionResponse + list: SubscriptionPlainModel[] } > } @@ -38,8 +42,9 @@ const useData = (view: FeedViewType) => { const subscriptions = structuredClone(data) for (const subscription of subscriptions) { - if (!subscription.category && subscription.feeds.siteUrl) { - const { domain } = parse(subscription.feeds.siteUrl) + const feed = getFeedById(subscription.feedId) + if (!subscription.category && feed && feed.siteUrl) { + const { domain } = parse(feed.siteUrl) if (domain) { if (!domains[domain]) { domains[domain] = 0 @@ -50,11 +55,13 @@ const useData = (view: FeedViewType) => { } for (const subscription of subscriptions) { + const feed = getFeedById(subscription.feedId) + if (!feed) continue if (!subscription.category) { - if (subscription.feeds.siteUrl) { + if (feed.siteUrl) { // FIXME @DIYgod // The logic here makes it impossible to remove the auto-generated category based on domain - const { domain } = parse(subscription.feeds.siteUrl) + const { domain } = parse(feed.siteUrl) if (domain && domains[domain] > 1) { subscription.category = domain.slice(0, 1).toUpperCase() + domain.slice(1) @@ -94,7 +101,6 @@ export function FeedList({ }) { const [expansion, setExpansion] = useState(false) const data = useData(view) - const activeList = useFeedActiveList() useBizQuery(Queries.subscription.unreadAll()) @@ -108,8 +114,6 @@ export function FeedList({ return unread }) - const { setActiveList } = feedActions - const sortedByUnread = useUnreadStore((state) => data?.list?.sort( (a, b) => @@ -118,6 +122,9 @@ export function FeedList({ ), ) + const feedId = useRouteFeedId() + const navigate = useNavigateEntry() + return (
{!hideTitle && ( @@ -130,10 +137,10 @@ export function FeedList({ onClick={(e) => { e.stopPropagation() if (view !== undefined) { - setActiveList({ + navigate({ + entryId: null, + feedId: null, level: levels.view, - id: view, - name: views[view].name, view, }) } @@ -160,15 +167,15 @@ export function FeedList({
{ e.stopPropagation() if (view !== undefined) { - setActiveList({ - level: levels.folder, - id: FEED_COLLECTION_LIST, - name: "Collections", + navigate({ + entryId: null, + feedId: FEED_COLLECTION_LIST, + level: levels.feed, view, }) } diff --git a/src/renderer/src/pages/(external)/(with-layout)/feed/[:id]/index.tsx b/src/renderer/src/pages/(external)/(with-layout)/feed/[id]/index.tsx similarity index 100% rename from src/renderer/src/pages/(external)/(with-layout)/feed/[:id]/index.tsx rename to src/renderer/src/pages/(external)/(with-layout)/feed/[id]/index.tsx diff --git a/src/renderer/src/pages/(external)/(with-layout)/feed/[:id]/layout.tsx b/src/renderer/src/pages/(external)/(with-layout)/feed/[id]/layout.tsx similarity index 100% rename from src/renderer/src/pages/(external)/(with-layout)/feed/[:id]/layout.tsx rename to src/renderer/src/pages/(external)/(with-layout)/feed/[id]/layout.tsx diff --git a/src/renderer/src/pages/(main)/(context)/layout.tsx b/src/renderer/src/pages/(main)/(context)/layout.tsx deleted file mode 100644 index 0341d292e..000000000 --- a/src/renderer/src/pages/(main)/(context)/layout.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { setMainContainerElement } from "@renderer/atoms" -import { FeedColumn } from "@renderer/modules/feed-column" -import { Outlet } from "react-router-dom" - -export function Component() { - return ( -
-
- -
- {/* NOTE: tabIndex for main element can get by `document.activeElement` */} -
- -
-
- ) -} diff --git a/src/renderer/src/pages/(main)/(context)/(nonhome)/discover/index.tsx b/src/renderer/src/pages/(main)/(layer)/(subview)/discover/index.tsx similarity index 100% rename from src/renderer/src/pages/(main)/(context)/(nonhome)/discover/index.tsx rename to src/renderer/src/pages/(main)/(layer)/(subview)/discover/index.tsx diff --git a/src/renderer/src/pages/(main)/(context)/(nonhome)/discover/layout.tsx b/src/renderer/src/pages/(main)/(layer)/(subview)/discover/layout.tsx similarity index 100% rename from src/renderer/src/pages/(main)/(context)/(nonhome)/discover/layout.tsx rename to src/renderer/src/pages/(main)/(layer)/(subview)/discover/layout.tsx diff --git a/src/renderer/src/pages/(main)/(context)/(nonhome)/layout.tsx b/src/renderer/src/pages/(main)/(layer)/(subview)/layout.tsx similarity index 100% rename from src/renderer/src/pages/(main)/(context)/(nonhome)/layout.tsx rename to src/renderer/src/pages/(main)/(layer)/(subview)/layout.tsx diff --git a/src/renderer/src/pages/(main)/(context)/(nonhome)/profile/index.tsx b/src/renderer/src/pages/(main)/(layer)/(subview)/profile/index.tsx similarity index 100% rename from src/renderer/src/pages/(main)/(context)/(nonhome)/profile/index.tsx rename to src/renderer/src/pages/(main)/(layer)/(subview)/profile/index.tsx diff --git a/src/renderer/src/pages/(main)/(context)/(nonhome)/profile/layout.tsx b/src/renderer/src/pages/(main)/(layer)/(subview)/profile/layout.tsx similarity index 100% rename from src/renderer/src/pages/(main)/(context)/(nonhome)/profile/layout.tsx rename to src/renderer/src/pages/(main)/(layer)/(subview)/profile/layout.tsx diff --git a/src/renderer/src/pages/(main)/(layer)/feeds/[feedId]/[entryId]/index.tsx b/src/renderer/src/pages/(main)/(layer)/feeds/[feedId]/[entryId]/index.tsx new file mode 100644 index 000000000..190cba1d8 --- /dev/null +++ b/src/renderer/src/pages/(main)/(layer)/feeds/[feedId]/[entryId]/index.tsx @@ -0,0 +1,20 @@ +import { useRouteView } from "@renderer/hooks/biz/useRouteParams" +import { ROUTE_FEED_PENDING, views } from "@renderer/lib/constants" +import { EntryContent } from "@renderer/modules/entry-content" +import { AnimatePresence } from "framer-motion" +import { useParams } from "react-router-dom" + +export const Component = () => { + const { entryId } = useParams() + const view = useRouteView() + const inWideMode = view ? views[view].wideMode : false + return ( + + {!inWideMode && ( +
+ +
+ )} +
+ ) +} diff --git a/src/renderer/src/pages/(main)/(context)/index.tsx b/src/renderer/src/pages/(main)/(layer)/feeds/[feedId]/layout.tsx similarity index 61% rename from src/renderer/src/pages/(main)/(context)/index.tsx rename to src/renderer/src/pages/(main)/(layer)/feeds/[feedId]/layout.tsx index 7ddd04a43..762b5d617 100644 --- a/src/renderer/src/pages/(main)/(context)/index.tsx +++ b/src/renderer/src/pages/(main)/(layer)/feeds/[feedId]/layout.tsx @@ -1,36 +1,21 @@ +import { useRouteView } from "@renderer/hooks/biz/useRouteParams" import { views } from "@renderer/lib/constants" import { cn } from "@renderer/lib/utils" import { EntryColumn } from "@renderer/modules/entry-column" -import { EntryContent } from "@renderer/modules/entry-content" -import { - feedActions, - uiActions, - useFeedStore, - useUIStore, -} from "@renderer/store" -import { AnimatePresence } from "framer-motion" -import { useEffect, useMemo, useRef } from "react" +import { uiActions, useUIStore } from "@renderer/store" +import { useMemo, useRef } from "react" import { HotkeysProvider } from "react-hotkeys-hook" import { useResizable } from "react-resizable-layout" -import { useShallow } from "zustand/react/shallow" +import { Outlet } from "react-router-dom" export function Component() { - const { activeEntry, activeList } = useFeedStore( - useShallow((state) => ({ - activeList: state.activeList, - activeEntry: state.activeEntryId, - })), - ) - const { setActiveEntry } = feedActions - useEffect(() => { - setActiveEntry(null) - }, [activeList?.id]) const containerRef = useRef(null) // Memo this initial value to avoid re-render // eslint-disable-next-line react-compiler/react-compiler const entryColWidth = useMemo(() => useUIStore.getState().entryColWidth, []) - + const view = useRouteView() + const inWideMode = view ? views[view].wideMode : false const { position, separatorProps } = useResizable({ axis: "x", min: 300, @@ -42,7 +27,6 @@ export function Component() { }, }) - const inWideMode = activeList && views[activeList.view].wideMode return (
@@ -64,13 +48,7 @@ export function Component() { className="h-full w-px shrink-0 cursor-ew-resize hover:bg-border" /> )} - - {!inWideMode && ( -
- -
- )} -
+
) diff --git a/src/renderer/src/pages/(main)/(layer)/layout.tsx b/src/renderer/src/pages/(main)/(layer)/layout.tsx new file mode 100644 index 000000000..0cb99dba7 --- /dev/null +++ b/src/renderer/src/pages/(main)/(layer)/layout.tsx @@ -0,0 +1 @@ +export { Outlet as Component } from "react-router-dom" diff --git a/src/renderer/src/pages/(main)/index.tsx b/src/renderer/src/pages/(main)/index.tsx new file mode 100644 index 000000000..c6d607ec7 --- /dev/null +++ b/src/renderer/src/pages/(main)/index.tsx @@ -0,0 +1,11 @@ +import { levels, ROUTE_FEED_PENDING } from "@renderer/lib/constants" +import { FeedViewType } from "@renderer/lib/enum" +import { redirect } from "react-router-dom" + +export function Component() { + return null +} + +export const loader = () => + // navigate to the first feed + redirect(`/feeds/${ROUTE_FEED_PENDING}?view=${FeedViewType.Articles}&level=${levels.view}`) diff --git a/src/renderer/src/pages/(main)/layout.tsx b/src/renderer/src/pages/(main)/layout.tsx index 6702108cd..0341d292e 100644 --- a/src/renderer/src/pages/(main)/layout.tsx +++ b/src/renderer/src/pages/(main)/layout.tsx @@ -1,22 +1,17 @@ -import { feedActions, useFeedStore } from "@renderer/store" -import { useEffect } from "react" -import { Outlet, useNavigate } from "react-router-dom" +import { setMainContainerElement } from "@renderer/atoms" +import { FeedColumn } from "@renderer/modules/feed-column" +import { Outlet } from "react-router-dom" -function MainLayout() { - const navigate = useNavigate() - - const changed = useFeedStore( - (state) => `${state.activeList?.view}-${state.activeList?.id}`, +export function Component() { + return ( +
+
+ +
+ {/* NOTE: tabIndex for main element can get by `document.activeElement` */} +
+ +
+
) - - useEffect(() => { - feedActions.setActiveEntry(null) - if (changed) { - navigate("/") - } - }, [changed]) - - return } - -export { MainLayout as Component } diff --git a/src/renderer/src/providers/biz-router-provider.tsx b/src/renderer/src/providers/biz-router-provider.tsx new file mode 100644 index 000000000..68ef8b612 --- /dev/null +++ b/src/renderer/src/providers/biz-router-provider.tsx @@ -0,0 +1,17 @@ +import { setNavigate, setRoute } from "@renderer/atoms" +import { useLayoutEffect } from "react" +import { useNavigate, useParams, useSearchParams } from "react-router-dom" + +export const BizRouterProvider = () => { + const [searchParams] = useSearchParams() + const params = useParams() + const nav = useNavigate() + useLayoutEffect(() => { + setRoute({ + params, + searchParams, + }) + setNavigate({ fn: nav }) + }, [searchParams, params, nav]) + return null +} diff --git a/src/renderer/src/providers/root-providers.tsx b/src/renderer/src/providers/root-providers.tsx index ccd66139e..4a5d362f8 100644 --- a/src/renderer/src/providers/root-providers.tsx +++ b/src/renderer/src/providers/root-providers.tsx @@ -9,6 +9,8 @@ import { Provider } from "jotai" import type { FC, PropsWithChildren } from "react" import { HelmetProvider } from "react-helmet-async" +import { BizRouterProvider } from "./biz-router-provider" + const loadFeatures = () => import("../framer-lazy-feature").then((res) => res.default) export const RootProviders: FC = ({ children }) => ( @@ -28,6 +30,7 @@ export const RootProviders: FC = ({ children }) => ( {children} + diff --git a/src/renderer/src/queries/entries.ts b/src/renderer/src/queries/entries.ts index 09223734f..af1192c15 100644 --- a/src/renderer/src/queries/entries.ts +++ b/src/renderer/src/queries/entries.ts @@ -1,7 +1,7 @@ import { useBizInfiniteQuery, useBizQuery } from "@renderer/hooks" import { apiClient } from "@renderer/lib/api-fetch" import { defineQuery } from "@renderer/lib/defineQuery" -import { entryActions } from "@renderer/store/entry/entry" +import { entryActions } from "@renderer/store" export const entries = { entries: ({ diff --git a/src/renderer/src/router.tsx b/src/renderer/src/router.tsx index 23e7f7386..ed417bc4d 100644 --- a/src/renderer/src/router.tsx +++ b/src/renderer/src/router.tsx @@ -5,7 +5,6 @@ import { buildGlobRoutes } from "./lib/route-builder" const globTree = import.meta.glob("./pages/**/*.tsx") const tree = buildGlobRoutes(globTree) -// console.log(tree) export const router = createBrowserRouter([ { diff --git a/src/renderer/src/store/entry/hooks.ts b/src/renderer/src/store/entry/hooks.ts index ba0c746c9..6c96dc2a0 100644 --- a/src/renderer/src/store/entry/hooks.ts +++ b/src/renderer/src/store/entry/hooks.ts @@ -4,13 +4,13 @@ import type { EntryModel } from "@renderer/models" import { useShallow } from "zustand/react/shallow" import { useFeedIdByView } from "../subscription" -import { useEntryStore } from "./entry" +import { useEntryStore } from "./store" interface EntryFilter { unread?: boolean } -export const useEntry = (entryId: string | null): EntryModel | null => +export const useEntry = (entryId: Nullable): EntryModel | null => useEntryStore(useShallow((state) => entryId ? state.flatMapEntries[entryId] : null)) // feedId: single feedId, multiple feedId joint by `,`, and `collections` export const useEntryIdsByFeedId = (feedId: string, filter?: EntryFilter) => diff --git a/src/renderer/src/store/entry/index.ts b/src/renderer/src/store/entry/index.ts index 812f374e0..a92fe14dc 100644 --- a/src/renderer/src/store/entry/index.ts +++ b/src/renderer/src/store/entry/index.ts @@ -1,3 +1,3 @@ -export * from "./entry" export * from "./hooks" +export * from "./store" export * from "./types" diff --git a/src/renderer/src/store/entry/entry.ts b/src/renderer/src/store/entry/store.ts similarity index 95% rename from src/renderer/src/store/entry/entry.ts rename to src/renderer/src/store/entry/store.ts index 145c0d973..b30dc5713 100644 --- a/src/renderer/src/store/entry/entry.ts +++ b/src/renderer/src/store/entry/store.ts @@ -4,6 +4,7 @@ import type { EntryModel } from "@renderer/models" import { produce } from "immer" import { merge, omit } from "lodash-es" +import { feedActions } from "../feed" import { unreadActions } from "../unread" import { createZustandStore, getStoreActions } from "../utils/helper" import type { EntryActions, EntryState } from "./types" @@ -132,6 +133,10 @@ export const useEntryStore = createZustandStore( draft.flatMapEntries[entry.entries.id] || {}, entry, ) + + const feeds = entries.map((entry) => entry.feeds) + // Insert to feed store + feedActions.upsertMany(feeds) } return draft }), diff --git a/src/renderer/src/store/feed.ts b/src/renderer/src/store/feed.ts deleted file mode 100644 index 19ce197ba..000000000 --- a/src/renderer/src/store/feed.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { ActiveEntryId, ActiveList } from "@renderer/models" -import { create } from "zustand" - -import { getStoreActions } from "./utils/helper" - -interface FeedStoreActions { - setActiveList: (value: ActiveList) => void - setActiveEntry: (value: ActiveEntryId) => void -} -interface FeedStoreState { - activeList: ActiveList - activeEntryId: ActiveEntryId -} - -type FeedStore = FeedStoreState & Readonly -export const useFeedStore = create((set) => ({ - activeList: { - level: "view", - id: 0, - name: "Articles", - view: 0, - }, - activeEntryId: null, - activeEntryIndex: null, - - // Actions - setActiveEntry: (value) => set({ activeEntryId: value }), - setActiveList: (value) => set({ activeList: value }), -})) - -export const feedActions = getStoreActions(useFeedStore) - -export const getCurrentFeedId = () => useFeedStore.getState().activeList.id -export const getCurrentEntryId = () => useFeedStore.getState().activeEntryId - -/** Hooks */ -export const useFeedActiveList = () => - useFeedStore((state) => state.activeList) diff --git a/src/renderer/src/store/feed/hooks.ts b/src/renderer/src/store/feed/hooks.ts new file mode 100644 index 000000000..06d3bb1dd --- /dev/null +++ b/src/renderer/src/store/feed/hooks.ts @@ -0,0 +1,23 @@ +import { useRouteParms } from "@renderer/hooks/biz/useRouteParams" +import { ROUTE_FEED_PENDING, views } from "@renderer/lib/constants" +import type { FeedModel } from "@renderer/models" +import { useShallow } from "zustand/react/shallow" + +import { useFeedStore } from "./store" + +export const useFeedById = (feedId: Nullable): FeedModel | null => + useFeedStore((state) => (feedId ? state.feeds[feedId] : null)) + +export const useFeedByIdSelector = ( + feedId: Nullable, + selector: (feed: FeedModel) => T, +) => useFeedStore(useShallow((state) => (feedId && state.feeds[feedId] ? selector(state.feeds[feedId]) : null))) + +export const useFeedHeaderTitle = () => { + const { feedId: currentFeedId, category, view } = useRouteParms() + + const feedTitle = useFeedByIdSelector(currentFeedId, (feed) => feed.title) + return currentFeedId === ROUTE_FEED_PENDING ? + views[view].name : + category || feedTitle +} diff --git a/src/renderer/src/store/feed/index.ts b/src/renderer/src/store/feed/index.ts new file mode 100644 index 000000000..a92fe14dc --- /dev/null +++ b/src/renderer/src/store/feed/index.ts @@ -0,0 +1,3 @@ +export * from "./hooks" +export * from "./store" +export * from "./types" diff --git a/src/renderer/src/store/feed/store.ts b/src/renderer/src/store/feed/store.ts new file mode 100644 index 000000000..2a0f58e95 --- /dev/null +++ b/src/renderer/src/store/feed/store.ts @@ -0,0 +1,40 @@ +import type { FeedModel } from "@renderer/models" +import { produce } from "immer" + +import { createZustandStore, getStoreActions } from "../utils/helper" +import type { FeedActions, FeedState } from "./types" + +export const useFeedStore = createZustandStore( + "feed", + { + version: 1, + }, +)((set) => ({ + feeds: {}, + clear() { + set({ feeds: {} }) + }, + upsertMany(feeds) { + set((state) => + produce(state, (state) => { + for (const feed of feeds) { + if (feed.id) { state.feeds[feed.id] = feed } + } + }), + ) + }, + + optimisticUpdate(feedId, changed) { + set((state) => + produce(state, (state) => { + const feed = state.feeds[feedId] + if (!feed) return + + Object.assign(feed, changed) + }), + ) + }, +})) +export const feedActions = getStoreActions(useFeedStore) + +export const getFeedById = (feedId: string): Nullable => useFeedStore.getState().feeds[feedId] diff --git a/src/renderer/src/store/feed/types.ts b/src/renderer/src/store/feed/types.ts new file mode 100644 index 000000000..927666226 --- /dev/null +++ b/src/renderer/src/store/feed/types.ts @@ -0,0 +1,13 @@ +import type { FeedModel } from "@renderer/models" + +type FeedId = string + +export interface FeedState { + feeds: Record +} + +export interface FeedActions { + upsertMany: (feeds: FeedModel[]) => void + optimisticUpdate: (feedId: FeedId, changed: Partial) => void + clear: () => void +} diff --git a/src/renderer/src/store/subscription.ts b/src/renderer/src/store/subscription.ts index 4e1a47fe6..ed42ec77d 100644 --- a/src/renderer/src/store/subscription.ts +++ b/src/renderer/src/store/subscription.ts @@ -2,20 +2,23 @@ import { apiClient } from "@renderer/lib/api-fetch" import { FeedViewType } from "@renderer/lib/enum" import type { SubscriptionModel } from "@renderer/models" import { produce } from "immer" +import { omit } from "lodash-es" -import { entryActions } from "./entry/entry" +import { entryActions } from "./entry/store" +import { feedActions } from "./feed" import { unreadActions } from "./unread" import { createZustandStore, getStoreActions } from "./utils/helper" type FeedId = string +export type SubscriptionPlainModel = Omit interface SubscriptionState { - data: Record + data: Record dataIdByView: Record } interface SubscriptionActions { - upsert: (feedId: FeedId, subscription: SubscriptionModel) => void - fetchByView: (view?: FeedViewType) => Promise + upsertMany: (subscription: SubscriptionPlainModel[]) => void + fetchByView: (view?: FeedViewType) => Promise markReadByView: (view?: FeedViewType) => void internal_reset: () => void clear: () => void @@ -66,23 +69,20 @@ export const useSubscriptionStore = createZustandStore< })) } - set((state) => - produce(state, (state) => { - res.data.forEach((subscription) => { - state.data[subscription.feeds.id] = subscription - state.dataIdByView[subscription.view].push(subscription.feeds.id) - return state - }) - }), - ) + get().upsertMany(res.data) + feedActions.upsertMany(res.data.map((s) => s.feeds)) return res.data }, - upsert: (feedId, subscription) => { + upsertMany: (subscriptions) => { set((state) => produce(state, (state) => { - state.data[feedId] = subscription - return state + subscriptions.forEach((subscription) => { + state.data[subscription.feedId] = omit(subscription, "feeds") + state.dataIdByView[subscription.view].push(subscription.feedId) + + return state + }) }), ) }, diff --git a/src/renderer/src/store/utils/local.ts b/src/renderer/src/store/utils/local.ts index 112815d5e..eb304c4ae 100644 --- a/src/renderer/src/store/utils/local.ts +++ b/src/renderer/src/store/utils/local.ts @@ -1,11 +1,12 @@ -import { entryActions } from "../entry/entry" +import { entryActions } from "../entry/store" +import { feedActions } from "../feed" import { subscriptionActions } from "../subscription" import { uiActions } from "../ui" import { unreadActions } from "../unread" export const clearLocalPersistStoreData = () => { // All clear and reset method will aggregate here - [entryActions, subscriptionActions, unreadActions, uiActions].forEach( + [entryActions, subscriptionActions, unreadActions, uiActions, feedActions].forEach( (actions) => { actions.clear() },