From 9920c46fd677cb8c9e88e62a3ce75c4a3a73fa03 Mon Sep 17 00:00:00 2001 From: Innei Date: Thu, 30 May 2024 15:02:52 +0800 Subject: [PATCH] feat: mark read out of scroll (#27) * feat: mark read out of scroll Signed-off-by: Innei * chore: rename Signed-off-by: Innei --------- Signed-off-by: Innei --- package.json | 3 + pnpm-lock.yaml | 42 ++++ .../components/entry-column/article-item.tsx | 11 +- .../src/components/entry-column/index.tsx | 191 +++++++++++++----- .../components/entry-column/item-wrapper.tsx | 27 ++- .../entry-column/notification-item.tsx | 9 +- .../components/entry-column/picture-item.tsx | 5 +- .../entry-column/social-media-item.tsx | 5 +- .../src/components/entry-column/types.ts | 5 + .../components/entry-column/video-item.tsx | 5 +- src/renderer/src/hooks/useUpdateEntry.tsx | 8 +- src/renderer/src/lib/ns.ts | 2 + src/renderer/src/lib/types.ts | 1 + 13 files changed, 228 insertions(+), 86 deletions(-) create mode 100644 src/renderer/src/components/entry-column/types.ts create mode 100644 src/renderer/src/lib/ns.ts diff --git a/package.json b/package.json index 0be74e57a6..bfc13f49d0 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "jotai-dark": "^0.3.0", "jotai-effect": "^1.0.0", "lethargy": "1.0.9", + "lodash-es": "4.17.21", "lucide-react": "0.379.0", "ofetch": "1.3.4", "react-hook-form": "7.51.5", @@ -74,6 +75,7 @@ "tldts": "6.1.21", "unified": "11.0.4", "unist-util-visit": "5.0.0", + "usehooks-ts": "3.1.0", "vfile": "6.0.1", "zod": "3.23.8", "zustand": "4.5.2" @@ -83,6 +85,7 @@ "@electron-toolkit/tsconfig": "^1.0.1", "@iconify-json/mingcute": "1.1.17", "@tailwindcss/typography": "0.5.13", + "@types/lodash-es": "4.17.12", "@types/node": "^20.12.12", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c974ecc45..c4df43e9a7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,9 @@ importers: lethargy: specifier: 1.0.9 version: 1.0.9 + lodash-es: + specifier: 4.17.21 + version: 4.17.21 lucide-react: specifier: 0.379.0 version: 0.379.0(react@18.3.1) @@ -161,6 +164,9 @@ importers: unist-util-visit: specifier: 5.0.0 version: 5.0.0 + usehooks-ts: + specifier: 3.1.0 + version: 3.1.0(react@18.3.1) vfile: specifier: 6.0.1 version: 6.0.1 @@ -183,6 +189,9 @@ importers: '@tailwindcss/typography': specifier: 0.5.13 version: 0.5.13(tailwindcss@3.4.3) + '@types/lodash-es': + specifier: 4.17.12 + version: 4.17.12 '@types/node': specifier: ^20.12.12 version: 20.12.12 @@ -1843,6 +1852,12 @@ packages: '@types/keyv@3.1.4': resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.4': + resolution: {integrity: sha512-wYCP26ZLxaT3R39kiN2+HcJ4kTd3U1waI/cY7ivWYqFP6pW3ZNpvi6Wd6PHZx7T/t8z0vlkXMg3QYLa7DZ/IJQ==} + '@types/mdast@4.0.3': resolution: {integrity: sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg==} @@ -3612,9 +3627,15 @@ packages: resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + lodash.castarray@4.4.0: resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==} + lodash.debounce@4.0.8: + resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -4896,6 +4917,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 + usehooks-ts@3.1.0: + resolution: {integrity: sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw==} + engines: {node: '>=16.15.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 + utf8-byte-length@1.0.4: resolution: {integrity: sha512-4+wkEYLBbWxqTahEsWrhxepcoVOJ+1z5PGIjPZxRkytcdSUaNjIjBM7Xn8E+pdSuV7SzvWovBFA54FO0JSoqhA==} @@ -6813,6 +6840,12 @@ snapshots: dependencies: '@types/node': 20.12.12 + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.4 + + '@types/lodash@4.17.4': {} + '@types/mdast@4.0.3': dependencies: '@types/unist': 3.0.2 @@ -9064,8 +9097,12 @@ snapshots: dependencies: p-locate: 6.0.0 + lodash-es@4.17.21: {} + lodash.castarray@4.4.0: {} + lodash.debounce@4.0.8: {} + lodash.defaults@4.2.0: {} lodash.difference@4.5.0: {} @@ -10519,6 +10556,11 @@ snapshots: dependencies: react: 18.3.1 + usehooks-ts@3.1.0(react@18.3.1): + dependencies: + lodash.debounce: 4.0.8 + react: 18.3.1 + utf8-byte-length@1.0.4: {} util-deprecate@1.0.2: {} diff --git a/src/renderer/src/components/entry-column/article-item.tsx b/src/renderer/src/components/entry-column/article-item.tsx index faf04161c2..179266befd 100644 --- a/src/renderer/src/components/entry-column/article-item.tsx +++ b/src/renderer/src/components/entry-column/article-item.tsx @@ -1,15 +1,16 @@ import { FeedIcon } from "@renderer/components/feed-icon" import { Image } from "@renderer/components/ui/image" import dayjs from "@renderer/lib/dayjs" -import type { EntriesResponse } from "@renderer/lib/types" import { cn } from "@renderer/lib/utils" -export function ArticleItem({ entry }: { entry: EntriesResponse[number] }) { +import type { UniversalItemProps } from "./types" + +export function ArticleItem({ entry }: UniversalItemProps) { return (
-
+
{entry.feeds.title} ยท @@ -30,7 +31,9 @@ export function ArticleItem({ entry }: { entry: EntriesResponse[number] }) { > {entry.entries.title}
-
{entry.entries.description}
+
+ {entry.entries.description} +
{entry.entries.images?.[0] && ( ( + buildStorageNS("entry-tab"), + "unread", +) export function EntryColumn() { - const [filterTab, setFilterTab] = useState("unread") - const activeList = useFeedStore((state) => state.activeList) - const entries = useEntries({ - level: activeList?.level, - id: activeList?.id, - view: activeList?.view, - ...(filterTab === "unread" && { read: false }), - }) + const entries = useEntriesByTab() + + const entriesIds = (entries.data?.pages?.flatMap((page) => + 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 + let Item: FC switch (activeList?.view) { case 0: { Item = ArticleItem @@ -54,60 +74,129 @@ export function EntryColumn() { } } - const List = useMemo(() => forwardRef((props, ref: LegacyRef) => ( - - )), [filterTab, activeList]) + const handleRangeChange = useEventCallback( + 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[] + 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 }), + ) + } + } - const Header = useMemo(() => () => ( -
+ await Promise.all(requestTasks) + + // TODO optimistic update + + if (requestTasks.length > 0) entries.refetch() + }, + 1000, + { leading: false }, + ), + ) + + return ( +
+ + entries.hasNextPage && entries.fetchNextPage()} + data={entries.data?.pages.flatMap((page) => page.data)} + itemContent={(_, entry) => { + if (!entry) return null + return ( + + + + ) + }} + /> +
+ ) +} + +const useEntriesByTab = () => { + const activeList = useFeedStore(useShallow((state) => state.activeList)) + const filterTab = useAtomValue(filterTabAtom) + + return useEntries({ + level: activeList?.level, + id: activeList?.id, + view: activeList?.view, + ...(filterTab === "unread" && { read: false }), + }) +} + +const ListHeader: FC = () => { + const activeList = useFeedStore(useShallow((state) => state.activeList)) + const [filterTab, setFilterTab] = useAtom(filterTabAtom) + const entries = useEntriesByTab() + const total = entries.data?.pages?.reduce( + (acc, page) => acc + (page.data?.length || 0), + 0, + ) + return ( +
{activeList?.name}
- {entries.data?.pages?.[0].total} + {total} {" "} Items
+ {/* @ts-expect-error */} - Unread - All + + Unread + + + All +
- ), [activeList, entries.data?.pages?.[0].total]) + ) +} + +const ListContent = forwardRef((props, ref) => { + const activeList = useFeedStore(useShallow((state) => state.activeList)) return ( - - entries.hasNextPage && entries.fetchNextPage()} - data={entries.data?.pages} - itemContent={(_, page) => page?.data?.map((entry) => ( - - - - ))} + ) -} +}) diff --git a/src/renderer/src/components/entry-column/item-wrapper.tsx b/src/renderer/src/components/entry-column/item-wrapper.tsx index d8c02c9803..d981193290 100644 --- a/src/renderer/src/components/entry-column/item-wrapper.tsx +++ b/src/renderer/src/components/entry-column/item-wrapper.tsx @@ -1,5 +1,4 @@ import { useEntryActions } from "@renderer/hooks/useEntryActions" -import { usePrevious } from "@renderer/hooks/usePrevious" import { useUpdateEntry } from "@renderer/hooks/useUpdateEntry" import { showNativeMenu } from "@renderer/lib/native-menu" import type { EntriesResponse, EntryResponse } from "@renderer/lib/types" @@ -7,8 +6,6 @@ import { cn } from "@renderer/lib/utils" import { apiFetch } from "@renderer/queries/api-fetch" import { feedActions, useFeedStore } from "@renderer/store" import { useMutation } from "@tanstack/react-query" -import { useEffect } from "react" -import { useInView } from "react-intersection-observer" export function EntryItemWrapper({ entry, @@ -46,18 +43,18 @@ export function EntryItemWrapper({ }, }) - 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]) + // 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 + // if (!entry?.entries.url || view === undefined) return children return (
{ e.stopPropagation() feedActions.setActiveEntry(entry.entries.id) diff --git a/src/renderer/src/components/entry-column/notification-item.tsx b/src/renderer/src/components/entry-column/notification-item.tsx index e77e02e62b..49bff358b3 100644 --- a/src/renderer/src/components/entry-column/notification-item.tsx +++ b/src/renderer/src/components/entry-column/notification-item.tsx @@ -1,12 +1,9 @@ import { FeedIcon } from "@renderer/components/feed-icon" import dayjs from "@renderer/lib/dayjs" -import type { EntriesResponse } from "@renderer/lib/types" -export function NotificationItem({ - entry, -}: { - entry: EntriesResponse[number] -}) { +import type { UniversalItemProps } from "./types" + +export function NotificationItem({ entry }: UniversalItemProps) { return (
diff --git a/src/renderer/src/components/entry-column/picture-item.tsx b/src/renderer/src/components/entry-column/picture-item.tsx index fba1ff5c89..6d0054a62a 100644 --- a/src/renderer/src/components/entry-column/picture-item.tsx +++ b/src/renderer/src/components/entry-column/picture-item.tsx @@ -1,9 +1,10 @@ import { FeedIcon } from "@renderer/components/feed-icon" import { Image } from "@renderer/components/ui/image" import dayjs from "@renderer/lib/dayjs" -import type { EntriesResponse } from "@renderer/lib/types" -export function PictureItem({ entry }: { entry: EntriesResponse[number] }) { +import type { UniversalItemProps } from "./types" + +export function PictureItem({ entry }: UniversalItemProps) { 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 aaf0135cd4..40fed2e469 100644 --- a/src/renderer/src/components/entry-column/social-media-item.tsx +++ b/src/renderer/src/components/entry-column/social-media-item.tsx @@ -1,9 +1,10 @@ import { FeedIcon } from "@renderer/components/feed-icon" import { Image } from "@renderer/components/ui/image" import dayjs from "@renderer/lib/dayjs" -import type { EntriesResponse } from "@renderer/lib/types" -export function SocialMediaItem({ entry }: { entry: EntriesResponse[number] }) { +import type { UniversalItemProps } from "./types" + +export function SocialMediaItem({ entry }: UniversalItemProps) { return (
diff --git a/src/renderer/src/components/entry-column/types.ts b/src/renderer/src/components/entry-column/types.ts new file mode 100644 index 0000000000..93a3d56b0b --- /dev/null +++ b/src/renderer/src/components/entry-column/types.ts @@ -0,0 +1,5 @@ +import type { EntriesResponse } from "@renderer/lib/types" + +export interface UniversalItemProps { entry: EntriesResponse[number] } + +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 2dd43d1e51..d4e99d3d47 100644 --- a/src/renderer/src/components/entry-column/video-item.tsx +++ b/src/renderer/src/components/entry-column/video-item.tsx @@ -1,9 +1,10 @@ import { FeedIcon } from "@renderer/components/feed-icon" import { Image } from "@renderer/components/ui/image" import dayjs from "@renderer/lib/dayjs" -import type { EntriesResponse } from "@renderer/lib/types" -export function VideoItem({ entry }: { entry: EntriesResponse[number] }) { +import type { UniversalItemProps } from "./types" + +export function VideoItem({ entry }: UniversalItemProps) { return (
diff --git a/src/renderer/src/hooks/useUpdateEntry.tsx b/src/renderer/src/hooks/useUpdateEntry.tsx index 0a7fb222fd..a10f27a6d8 100644 --- a/src/renderer/src/hooks/useUpdateEntry.tsx +++ b/src/renderer/src/hooks/useUpdateEntry.tsx @@ -47,15 +47,15 @@ export const useUpdateEntry = ({ queryKey: ["subscriptions"], }) entriesData.forEach(([key, data]: [QueryKey, unknown]) => { - const chage = changed.read ? -1 : 1 + const change = changed.read ? -1 : 1 const assertData = data as SubscriptionsResponse const finaldata = produce(assertData, (assertData) => { for (const list of assertData.list) { for (const item of list.list) { if (item.feeds.id === feedId) { - assertData.unread += chage - list.unread += chage - item.unread = (item.unread || 0) + chage + assertData.unread += change + list.unread += change + item.unread = (item.unread || 0) + change } } } diff --git a/src/renderer/src/lib/ns.ts b/src/renderer/src/lib/ns.ts new file mode 100644 index 0000000000..016e6c1ec8 --- /dev/null +++ b/src/renderer/src/lib/ns.ts @@ -0,0 +1,2 @@ +const ns = "follow" +export const buildStorageNS = (key: string) => `${ns}:${key}` diff --git a/src/renderer/src/lib/types.ts b/src/renderer/src/lib/types.ts index a7eedca2b8..c0fe4e2c08 100644 --- a/src/renderer/src/lib/types.ts +++ b/src/renderer/src/lib/types.ts @@ -38,6 +38,7 @@ export type EntriesResponse = Array< >[number] > +export type EntryModel = EntriesResponse[number] export type DiscoverResponse = Array< Exclude< InferResponseType["data"],