diff --git a/src/renderer/src/components/entry-column/article-item.tsx b/src/renderer/src/components/entry-column/article-item.tsx index 179266befd..daf085e394 100644 --- a/src/renderer/src/components/entry-column/article-item.tsx +++ b/src/renderer/src/components/entry-column/article-item.tsx @@ -2,10 +2,17 @@ import { FeedIcon } from "@renderer/components/feed-icon" import { Image } from "@renderer/components/ui/image" import dayjs from "@renderer/lib/dayjs" import { cn } from "@renderer/lib/utils" +import { useEntry } from "@renderer/store/entry" +import { ReactVirtuosoItemPlaceholder } from "../ui/placeholder" import type { UniversalItemProps } from "./types" -export function ArticleItem({ entry }: UniversalItemProps) { +export function ArticleItem({ entryId }: UniversalItemProps) { + const entry = useEntry(entryId) + + // NOTE: prevent 0 height element, react virtuoso will not stop render any more + if (!entry) return + return (
diff --git a/src/renderer/src/components/entry-column/index.tsx b/src/renderer/src/components/entry-column/index.tsx index 7641285b2b..5f409a3555 100644 --- a/src/renderer/src/components/entry-column/index.tsx +++ b/src/renderer/src/components/entry-column/index.tsx @@ -1,10 +1,10 @@ import { Tabs, TabsList, TabsTrigger } from "@renderer/components/ui/tabs" import { buildStorageNS } from "@renderer/lib/ns" -import type { EntryModel } from "@renderer/lib/types" import { cn } from "@renderer/lib/utils" import { apiClient } from "@renderer/queries/api-fetch" import { useEntries } from "@renderer/queries/entries" import { useFeedStore } from "@renderer/store" +import { entryActions } from "@renderer/store/entry" import { m } from "framer-motion" import { useAtom, useAtomValue } from "jotai" import { atomWithStorage } from "jotai/utils" @@ -38,15 +38,6 @@ export function EntryColumn() { page.data?.map((entry) => entry.entries.id), ) || []) as string[] - const entriesId2Map = - entries.data?.pages?.reduce((acc, page) => { - if (!page.data) return acc - for (const entry of page.data) { - acc[entry.entries.id] = entry - } - return acc - }, {} as Record) ?? {} - let Item: FC switch (activeList?.view) { case 0: { @@ -78,28 +69,28 @@ export function EntryColumn() { debounce( async ({ startIndex }: ListRange) => { const idSlice = entriesIds?.slice(0, startIndex) + if (!idSlice) return - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const requestTasks = [] as Promise[] + const batchLikeIds = [] as string[] + const entriesId2Map = entryActions.getFlattenMapEntries() for (const id of idSlice) { const entry = entriesId2Map[id] + if (!entry) continue const isRead = entry.read if (!isRead) { - // TODO csrfToken should omit and batch request - requestTasks.push( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - apiClient.reads.$post({ json: { entryId: id } as any }), - ) + batchLikeIds.push(id) } } - await Promise.all(requestTasks) + if (batchLikeIds.length > 0) { + await apiClient.reads.$post({ json: { entryIds: batchLikeIds } }) - // TODO optimistic update - - if (requestTasks.length > 0) entries.refetch() + for (const id of batchLikeIds) { + entryActions.optimisticUpdate(id, { read: true }) + } + } }, 1000, { leading: false }, @@ -114,6 +105,8 @@ export function EntryColumn() { components={{ List: ListContent, }} + defaultItemHeight={320} + overscan={window.innerHeight} rangeChanged={handleRangeChange} totalCount={entriesIds?.length} endReached={() => entries.hasNextPage && entries.fetchNextPage()} @@ -123,10 +116,10 @@ export function EntryColumn() { return ( - + ) }} diff --git a/src/renderer/src/components/entry-column/item-wrapper.tsx b/src/renderer/src/components/entry-column/item-wrapper.tsx index d981193290..3196bc1133 100644 --- a/src/renderer/src/components/entry-column/item-wrapper.tsx +++ b/src/renderer/src/components/entry-column/item-wrapper.tsx @@ -1,21 +1,23 @@ import { useEntryActions } from "@renderer/hooks/useEntryActions" -import { useUpdateEntry } from "@renderer/hooks/useUpdateEntry" import { showNativeMenu } from "@renderer/lib/native-menu" -import type { EntriesResponse, EntryResponse } from "@renderer/lib/types" import { cn } from "@renderer/lib/utils" -import { apiFetch } from "@renderer/queries/api-fetch" +import { apiClient } from "@renderer/queries/api-fetch" import { feedActions, useFeedStore } from "@renderer/store" +import { entryActions, useEntry } from "@renderer/store/entry" import { useMutation } from "@tanstack/react-query" +import { ReactVirtuosoItemPlaceholder } from "../ui/placeholder" + export function EntryItemWrapper({ - entry, children, + entryId, view, }: { - entry: EntriesResponse[number] | EntryResponse + entryId: string children: React.ReactNode view?: number }) { + const entry = useEntry(entryId) const { items } = useEntryActions({ view, entry, @@ -23,38 +25,23 @@ export function EntryItemWrapper({ const activeEntry = useFeedStore((state) => state.activeEntry) - const updateEntry = useUpdateEntry({ - entryId: entry?.entries.id, - feedId: entry?.feeds.id, - }) - const read = useMutation({ mutationFn: async () => - apiFetch("/reads", { - method: "POST", - body: { - entryId: entry?.entries.id, + apiClient.reads.$post({ + json: { + entryIds: [entry.entries.id], }, }), - onSuccess: () => { - updateEntry({ + onMutate: () => { + entryActions.optimisticUpdate(entry.entries.id, { read: true, }) }, + // TODO 出错回退 }) - // const { ref, inView } = useInView({ - // threshold: 1, - // delay: 1000, - // }) - // const prevInView = usePrevious(inView) - // useEffect(() => { - // if (prevInView && !inView && !entry.read) { - // read.mutate() - // } - // }, [entry.read, inView, read, prevInView]) - - // if (!entry?.entries.url || view === undefined) return children + // NOTE: prevent 0 height element, react virtuoso will not stop render any more + if (!entry) return return (
return (
diff --git a/src/renderer/src/components/entry-column/picture-item.tsx b/src/renderer/src/components/entry-column/picture-item.tsx index 6d0054a62a..1efbad0d63 100644 --- a/src/renderer/src/components/entry-column/picture-item.tsx +++ b/src/renderer/src/components/entry-column/picture-item.tsx @@ -1,10 +1,14 @@ import { FeedIcon } from "@renderer/components/feed-icon" import { Image } from "@renderer/components/ui/image" import dayjs from "@renderer/lib/dayjs" +import { useEntry } from "@renderer/store/entry" +import { ReactVirtuosoItemPlaceholder } from "../ui/placeholder" import type { UniversalItemProps } from "./types" -export function PictureItem({ entry }: UniversalItemProps) { +export function PictureItem({ entryId }: UniversalItemProps) { + const entry = useEntry(entryId) + if (!entry) return return (
diff --git a/src/renderer/src/components/entry-column/social-media-item.tsx b/src/renderer/src/components/entry-column/social-media-item.tsx index c41fb654af..7c9fff54e4 100644 --- a/src/renderer/src/components/entry-column/social-media-item.tsx +++ b/src/renderer/src/components/entry-column/social-media-item.tsx @@ -1,10 +1,16 @@ import { FeedIcon } from "@renderer/components/feed-icon" import { Image } from "@renderer/components/ui/image" import dayjs from "@renderer/lib/dayjs" +import { useEntry } from "@renderer/store/entry" +import { ReactVirtuosoItemPlaceholder } from "../ui/placeholder" import type { UniversalItemProps } from "./types" -export function SocialMediaItem({ entry }: UniversalItemProps) { +export function SocialMediaItem({ entryId }: UniversalItemProps) { + const entry = useEntry(entryId) + + // NOTE: prevent 0 height element, react virtuoso will not stop render any more + if (!entry) return return (
diff --git a/src/renderer/src/components/entry-column/types.ts b/src/renderer/src/components/entry-column/types.ts index 93a3d56b0b..a5104cc3b0 100644 --- a/src/renderer/src/components/entry-column/types.ts +++ b/src/renderer/src/components/entry-column/types.ts @@ -1,5 +1,5 @@ -import type { EntriesResponse } from "@renderer/lib/types" - -export interface UniversalItemProps { entry: EntriesResponse[number] } +export interface UniversalItemProps { + entryId: string +} export type FilterTab = "all" | "unread" diff --git a/src/renderer/src/components/entry-column/video-item.tsx b/src/renderer/src/components/entry-column/video-item.tsx index d4e99d3d47..801c411a6d 100644 --- a/src/renderer/src/components/entry-column/video-item.tsx +++ b/src/renderer/src/components/entry-column/video-item.tsx @@ -1,10 +1,14 @@ import { FeedIcon } from "@renderer/components/feed-icon" import { Image } from "@renderer/components/ui/image" import dayjs from "@renderer/lib/dayjs" +import { useEntry } from "@renderer/store/entry" +import { ReactVirtuosoItemPlaceholder } from "../ui/placeholder" import type { UniversalItemProps } from "./types" -export function VideoItem({ entry }: UniversalItemProps) { +export function VideoItem({ entryId }: UniversalItemProps) { + const entry = useEntry(entryId) + if (!entry) return return (
diff --git a/src/renderer/src/components/entry-content/index.tsx b/src/renderer/src/components/entry-content/index.tsx index f27e8487fb..8131e7c79e 100644 --- a/src/renderer/src/components/entry-content/index.tsx +++ b/src/renderer/src/components/entry-content/index.tsx @@ -26,7 +26,7 @@ export function EntryContent({ entryId }: { entryId: ActiveEntry }) { return ( <> - + +// NOTE: prevent 0 height element, react virtuoso will not stop render any more +
diff --git a/src/renderer/src/hono.ts b/src/renderer/src/hono.ts index 726c70f5b6..1fc92be0fd 100644 --- a/src/renderer/src/hono.ts +++ b/src/renderer/src/hono.ts @@ -7,8 +7,7 @@ declare const routes: hono_hono_base.HonoBase = T extends (...args: any[]) => infer R ? R : never +export type CombinedObject = T & U export function useBizQuery< TQuery extends DefinedQuery, TError = FetchError | RequestError, @@ -28,13 +29,23 @@ export function useBizQuery< UseQueryOptions, "queryKey" | "queryFn" > = {}, -): UseQueryResult { +): CombinedObject< + UseQueryResult, + { key: TQuery["key"], fn: TQuery["fn"] } +> { // @ts-expect-error - return useQuery({ - queryKey: query.key, - queryFn: query.fn, - ...options, - }) + return Object.assign( + {}, + useQuery({ + queryKey: query.key, + queryFn: query.fn, + ...options, + }), + { + key: query.key, + fn: query.fn, + }, + ) } export function useBizInfiniteQuery< @@ -45,12 +56,22 @@ export function useBizInfiniteQuery< >( query: T, options: Omit, "queryKey" | "queryFn">, -): UseInfiniteQueryResult, FetchError | RequestError> { +): CombinedObject< + UseInfiniteQueryResult, FetchError | RequestError>, + { key: T["key"], fn: T["fn"] } +> { // @ts-expect-error - return useInfiniteQuery({ - // @ts-expect-error - queryFn: query.fn, - queryKey: query.key, - ...options, - }) + return Object.assign( + {}, + useInfiniteQuery({ + // @ts-expect-error + queryFn: query.fn, + queryKey: query.key, + ...options, + }), + { + key: query.key, + fn: query.fn, + }, + ) } diff --git a/src/renderer/src/hooks/useEntryActions.tsx b/src/renderer/src/hooks/useEntryActions.tsx index 85bd63a0e9..490a1134da 100644 --- a/src/renderer/src/hooks/useEntryActions.tsx +++ b/src/renderer/src/hooks/useEntryActions.tsx @@ -1,12 +1,9 @@ import { useToast } from "@renderer/components/ui/use-toast" -import { useUpdateEntry } from "@renderer/hooks/useUpdateEntry" import { client } from "@renderer/lib/client" import type { EntriesResponse } from "@renderer/lib/types" -import { apiFetch } from "@renderer/queries/api-fetch" -import { - useMutation, - useQuery, -} from "@tanstack/react-query" +import { apiClient, apiFetch } from "@renderer/queries/api-fetch" +import { entryActions } from "@renderer/store/entry" +import { useMutation, useQuery } from "@tanstack/react-query" import type { FetchError } from "ofetch" import { ofetch } from "ofetch" @@ -30,11 +27,6 @@ export const useEntryActions = ({ }, }) - const updateEntry = useUpdateEntry({ - entryId: entry?.entries.id, - feedId: entry?.feeds.id, - }) - const collect = useMutation({ mutationFn: async () => apiFetch("/collections", { @@ -43,13 +35,15 @@ export const useEntryActions = ({ entryId: entry?.entries.id, }, }), - onSuccess: () => { - updateEntry({ + onMutate() { + if (!entry) return + entryActions.optimisticUpdate(entry.entries.id, { collections: { createdAt: new Date().toISOString(), }, }) - + }, + onSuccess: () => { toast({ duration: 1000, description: "Collected.", @@ -64,11 +58,13 @@ export const useEntryActions = ({ entryId: entry?.entries.id, }, }), - onSuccess: () => { - updateEntry({ + onMutate() { + if (!entry) return + entryActions.optimisticUpdate(entry.entries.id, { collections: undefined, }) - + }, + onSuccess: () => { toast({ duration: 1000, description: "Uncollected.", @@ -77,14 +73,16 @@ export const useEntryActions = ({ }) const read = useMutation({ mutationFn: async () => - apiFetch("/reads", { - method: "POST", - body: { - entryId: entry?.entries.id, + entry && + apiClient.reads.$post({ + json: { + entryIds: [entry.entries.id], }, }), - onSuccess: () => { - updateEntry({ + + onMutate: () => { + if (!entry) return + entryActions.optimisticUpdate(entry.entries.id, { read: true, }) }, @@ -97,8 +95,9 @@ export const useEntryActions = ({ entryId: entry?.entries.id, }, }), - onSuccess: () => { - updateEntry({ + onMutate: () => { + if (!entry) return + entryActions.optimisticUpdate(entry.entries.id, { read: false, }) }, diff --git a/src/renderer/src/hooks/useUpdateEntry.tsx b/src/renderer/src/hooks/useUpdateEntry.tsx index a10f27a6d8..bcefae773f 100644 --- a/src/renderer/src/hooks/useUpdateEntry.tsx +++ b/src/renderer/src/hooks/useUpdateEntry.tsx @@ -5,6 +5,10 @@ import type { InfiniteData, QueryKey } from "@tanstack/react-query" import { useQueryClient } from "@tanstack/react-query" import { produce } from "immer" +/** + * + * @deprecated + */ export const useUpdateEntry = ({ entryId, feedId, diff --git a/src/renderer/src/pages/(external)/feed/index.tsx b/src/renderer/src/pages/(external)/feed/index.tsx index d099f8c7f2..37eed2e305 100644 --- a/src/renderer/src/pages/(external)/feed/index.tsx +++ b/src/renderer/src/pages/(external)/feed/index.tsx @@ -53,20 +53,20 @@ export function Component() { return ( <> {feed.data?.feed && ( -
-
+
+

{feed.data.feed.title}

-
{feed.data.feed.description}
+
{feed.data.feed.description}
{entries.data?.pages.map((page) => page.data?.map((entry) => ( - + )), )} diff --git a/src/renderer/src/queries/entries.ts b/src/renderer/src/queries/entries.ts index 58a1a19708..e671615bf5 100644 --- a/src/renderer/src/queries/entries.ts +++ b/src/renderer/src/queries/entries.ts @@ -1,10 +1,8 @@ -import { - UnprocessableEntityError, -} from "@renderer/biz/error" +import { UnprocessableEntityError } from "@renderer/biz/error" import { useBizInfiniteQuery } from "@renderer/hooks/useBizQuery" -import { levels } from "@renderer/lib/constants" import { defineQuery } from "@renderer/lib/defineQuery" import { apiClient } from "@renderer/queries/api-fetch" +import { entryActions } from "@renderer/store/entry" export const entries = { entries: ({ @@ -20,26 +18,15 @@ export const entries = { }) => defineQuery( ["entries", level, id, view, read], - async ({ pageParam }) => { - const params: { - feedId?: string - feedIdList?: string[] - } = {} - if (level === levels.folder) { - params.feedIdList = `${id}`.split(",") - } else if (level === levels.feed) { - params.feedId = `${id}` - } - const res = await apiClient.entries.$post({ - json: { - publishedAfter: pageParam as string, - view, - read, - ...params, - }, - }) - return await res.json() - }, + async ({ pageParam }) => + + entryActions.fetchEntries({ + level, + id, + view, + read, + pageParam: pageParam as string, + }), { rootKey: ["entries"], }, diff --git a/src/renderer/src/store/entry.ts b/src/renderer/src/store/entry.ts new file mode 100644 index 0000000000..43ba33637c --- /dev/null +++ b/src/renderer/src/store/entry.ts @@ -0,0 +1,120 @@ +import { levels } from "@renderer/lib/constants" +import type { EntryModel } from "@renderer/lib/types" +import { apiClient } from "@renderer/queries/api-fetch" +import type { InferResponseType } from "hono/client" +import { produce } from "immer" +import { create } from "zustand" + +type EntriesIdTable = Record> + +interface EntryState { + entries: EntriesIdTable + + flatMapEntries: Record +} + +interface EntryActions { + fetchEntries: (params: { + level?: string + id?: number | string + view?: number + read?: boolean + + pageParam?: string + }) => Promise> + upsert: (feedId: string, entry: EntryModel) => void + optimisticUpdate: (entryId: string, changed: Partial) => void + getFlattenMapEntries: () => Record +} + +export const useEntryStore = create( + (set, get) => ({ + entries: {}, + flatMapEntries: {}, + + actions: { + fetchEntries: async ({ + level, + id, + view, + read, + + pageParam, + }: { + level?: string + id?: number | string + view?: number + read?: boolean + + pageParam?: string + }) => { + const params: { + feedId?: string + feedIdList?: string[] + } = {} + if (level === levels.folder) { + params.feedIdList = `${id}`.split(",") + } else if (level === levels.feed) { + params.feedId = `${id}` + } + const res = await apiClient.entries.$post({ + json: { + publishedAfter: pageParam as string, + view, + read, + ...params, + }, + }) + + const data = await res.json() + + if (data.data) { + data.data.forEach((entry: EntryModel) => { + get().actions.upsert(entry.feeds.id, entry) + }) + } + return data + }, + + getFlattenMapEntries() { + return get().flatMapEntries + }, + optimisticUpdate(entryId: string, changed: Partial) { + set((state) => + produce(state, (draft) => { + const entry = draft.flatMapEntries[entryId] + if (!entry) return + Object.assign(entry, changed) + return draft + }), + ) + }, + + upsert(feedId: string, entry: EntryModel) { + set((state) => + produce(state, (draft) => { + if (!draft.entries[feedId]) { + draft.entries[feedId] = {} + } + draft.entries[feedId][entry.entries.id] = entry + draft.flatMapEntries[entry.entries.id] = entry + return draft + }), + ) + }, + }, + }), +) + +export const entryActions = useEntryStore.getState().actions + +export const useEntriesByFeedId = (feedId: string) => + useEntryStore((state) => state.entries[feedId]) +export const useEntry = (entryId: string) => + useEntryStore((state) => state.flatMapEntries[entryId]) + +Object.assign(window, { + get __entry() { + return useEntryStore.getState() + }, +})