diff --git a/package.json b/package.json index 18223f1..8ae10fe 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "drizzle-zod": "^0.8.3", "embla-carousel-react": "^8.6.0", "fast-xml-parser": "^5.3.1", + "idb-keyval": "^6.2.2", "jimp": "^1.6.0", "jotai": "^2.15.1", "jotai-optics": "^0.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6f1c74e..6099717 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,7 +137,7 @@ importers: version: 11.7.1(@trpc/server@11.7.1(typescript@5.9.3))(typescript@5.9.3) '@trpc/next': specifier: 11.7.1 - version: 11.7.1(@tanstack/react-query@5.90.7(react@19.2.0))(@trpc/client@11.7.1(@trpc/server@11.7.1(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.7.1(typescript@5.9.3))(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3) + version: 11.7.1(@tanstack/react-query@5.90.7(react@19.2.0))(@trpc/client@11.7.1(@trpc/server@11.7.1(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.7.1(typescript@5.9.3))(next@16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3) '@trpc/server': specifier: ^11.7.1 version: 11.7.1(typescript@5.9.3) @@ -152,7 +152,7 @@ importers: version: 19.1.0-rc.2 better-auth: specifier: ^1.3.34 - version: 1.3.34(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 1.3.34(next@16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -177,27 +177,30 @@ importers: fast-xml-parser: specifier: ^5.3.1 version: 5.3.1 + idb-keyval: + specifier: ^6.2.2 + version: 6.2.2 jimp: specifier: ^1.6.0 version: 1.6.0 jotai: specifier: ^2.15.1 - version: 2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0) + version: 2.15.1(@babel/core@7.26.10)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0) jotai-optics: specifier: ^0.4.0 - version: 0.4.0(jotai@2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0))(optics-ts@2.4.1) + version: 0.4.0(jotai@2.15.1(@babel/core@7.26.10)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0))(optics-ts@2.4.1) lucide-react: specifier: ^0.553.0 version: 0.553.0(react@19.2.0) next: specifier: 16.0.1 - version: 16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) next-mdx-remote: specifier: ^5.0.0 version: 5.0.0(@types/react@19.2.2)(react@19.2.0) next-pwa: specifier: ^5.6.0 - version: 5.6.0(@babel/core@7.28.5)(esbuild@0.25.12)(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(webpack@5.102.1(esbuild@0.25.10)) + version: 5.6.0(@babel/core@7.26.10)(esbuild@0.25.12)(next@16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(webpack@5.102.1(esbuild@0.25.10)) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -276,7 +279,7 @@ importers: version: 9.6.1 '@types/next-pwa': specifier: ^5.6.9 - version: 5.6.9(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + version: 5.6.9(@babel/core@7.26.10)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@types/node': specifier: ^24.10.0 version: 24.10.0 @@ -5024,6 +5027,9 @@ packages: resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==} engines: {node: '>=0.10.0'} + idb-keyval@6.2.2: + resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==} + idb@7.1.1: resolution: {integrity: sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==} @@ -10485,7 +10491,7 @@ snapshots: log-symbols: 4.1.0 module-punycode: punycode@2.3.1 next: 16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) - next-safe-action: 8.0.11(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + next-safe-action: 8.0.11(next@16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0) node-html-parser: 7.0.1 ora: 5.4.1 pretty-bytes: 6.1.1 @@ -10825,11 +10831,11 @@ snapshots: '@trpc/server': 11.7.1(typescript@5.9.3) typescript: 5.9.3 - '@trpc/next@11.7.1(@tanstack/react-query@5.90.7(react@19.2.0))(@trpc/client@11.7.1(@trpc/server@11.7.1(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.7.1(typescript@5.9.3))(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)': + '@trpc/next@11.7.1(@tanstack/react-query@5.90.7(react@19.2.0))(@trpc/client@11.7.1(@trpc/server@11.7.1(typescript@5.9.3))(typescript@5.9.3))(@trpc/server@11.7.1(typescript@5.9.3))(next@16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(typescript@5.9.3)': dependencies: '@trpc/client': 11.7.1(@trpc/server@11.7.1(typescript@5.9.3))(typescript@5.9.3) '@trpc/server': 11.7.1(typescript@5.9.3) - next: 16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 react-dom: 19.2.0(react@19.2.0) typescript: 5.9.3 @@ -10937,12 +10943,12 @@ snapshots: '@types/ms@2.1.0': {} - '@types/next-pwa@5.6.9(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': + '@types/next-pwa@5.6.9(@babel/core@7.26.10)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': dependencies: '@types/node': 24.10.0 '@types/react': 19.2.2 '@types/react-dom': 19.2.2(@types/react@19.2.2) - next: 13.5.11(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 13.5.11(@babel/core@7.26.10)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) workbox-build: 6.6.0 transitivePeerDependencies: - '@babel/core' @@ -11450,9 +11456,9 @@ snapshots: axobject-query@4.1.0: {} - babel-loader@8.4.1(@babel/core@7.28.5)(webpack@5.102.1(esbuild@0.25.10)): + babel-loader@8.4.1(@babel/core@7.26.10)(webpack@5.102.1(esbuild@0.25.10)): dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.26.10 find-cache-dir: 3.3.2 loader-utils: 2.0.4 make-dir: 3.1.0 @@ -11497,7 +11503,7 @@ snapshots: baseline-browser-mapping@2.8.25: {} - better-auth@1.3.34(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + better-auth@1.3.34(next@16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@better-auth/core': 1.3.34(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.18)(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1) '@better-auth/telemetry': 1.3.34(better-call@1.0.19)(jose@6.1.0)(kysely@0.28.8)(nanostores@1.0.1) @@ -11514,7 +11520,7 @@ snapshots: nanostores: 1.0.1 zod: 4.1.12 optionalDependencies: - next: 16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 react-dom: 19.2.0(react@19.2.0) @@ -12967,6 +12973,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + idb-keyval@6.2.2: {} + idb@7.1.1: {} ieee754@1.2.1: {} @@ -13252,14 +13260,14 @@ snapshots: jose@6.1.0: {} - jotai-optics@0.4.0(jotai@2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0))(optics-ts@2.4.1): + jotai-optics@0.4.0(jotai@2.15.1(@babel/core@7.26.10)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0))(optics-ts@2.4.1): dependencies: - jotai: 2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0) + jotai: 2.15.1(@babel/core@7.26.10)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0) optics-ts: 2.4.1 - jotai@2.15.1(@babel/core@7.28.5)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0): + jotai@2.15.1(@babel/core@7.26.10)(@babel/template@7.27.2)(@types/react@19.2.2)(react@19.2.0): optionalDependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.26.10 '@babel/template': 7.27.2 '@types/react': 19.2.2 react: 19.2.0 @@ -13904,12 +13912,12 @@ snapshots: - '@types/react' - supports-color - next-pwa@5.6.0(@babel/core@7.28.5)(esbuild@0.25.12)(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(webpack@5.102.1(esbuild@0.25.10)): + next-pwa@5.6.0(@babel/core@7.26.10)(esbuild@0.25.12)(next@16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(webpack@5.102.1(esbuild@0.25.10)): dependencies: - babel-loader: 8.4.1(@babel/core@7.28.5)(webpack@5.102.1(esbuild@0.25.10)) + babel-loader: 8.4.1(@babel/core@7.26.10)(webpack@5.102.1(esbuild@0.25.10)) clean-webpack-plugin: 4.0.0(webpack@5.102.1(esbuild@0.25.10)) globby: 11.1.0 - next: 16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) terser-webpack-plugin: 5.3.14(esbuild@0.25.12)(webpack@5.102.1(esbuild@0.25.10)) workbox-webpack-plugin: 6.6.0(webpack@5.102.1(esbuild@0.25.10)) workbox-window: 6.6.0 @@ -13922,9 +13930,9 @@ snapshots: - uglify-js - webpack - next-safe-action@8.0.11(next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0): + next-safe-action@8.0.11(next@16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: - next: 16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.0.0 react-dom: 19.0.0(react@19.0.0) @@ -13933,7 +13941,7 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - next@13.5.11(@babel/core@7.28.5)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + next@13.5.11(@babel/core@7.26.10)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@next/env': 13.5.11 '@swc/helpers': 0.5.2 @@ -13942,7 +13950,7 @@ snapshots: postcss: 8.4.31 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - styled-jsx: 5.1.1(@babel/core@7.28.5)(react@19.2.0) + styled-jsx: 5.1.1(@babel/core@7.26.10)(react@19.2.0) watchpack: 2.4.0 optionalDependencies: '@next/swc-darwin-arm64': 13.5.9 @@ -13982,7 +13990,7 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@16.0.1(@babel/core@7.28.5)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + next@16.0.1(@babel/core@7.26.10)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: '@next/env': 16.0.1 '@swc/helpers': 0.5.15 @@ -13990,7 +13998,7 @@ snapshots: postcss: 8.4.31 react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.0) + styled-jsx: 5.1.6(@babel/core@7.26.10)(react@19.2.0) optionalDependencies: '@next/swc-darwin-arm64': 16.0.1 '@next/swc-darwin-x64': 16.0.1 @@ -15178,12 +15186,12 @@ snapshots: dependencies: inline-style-parser: 0.2.6 - styled-jsx@5.1.1(@babel/core@7.28.5)(react@19.2.0): + styled-jsx@5.1.1(@babel/core@7.26.10)(react@19.2.0): dependencies: client-only: 0.0.1 react: 19.2.0 optionalDependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.26.10 styled-jsx@5.1.6(@babel/core@7.26.10)(react@19.0.0): dependencies: @@ -15192,12 +15200,12 @@ snapshots: optionalDependencies: '@babel/core': 7.26.10 - styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.0): + styled-jsx@5.1.6(@babel/core@7.26.10)(react@19.2.0): dependencies: client-only: 0.0.1 react: 19.2.0 optionalDependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.26.10 sucrase@3.35.0: dependencies: diff --git a/src/app/(feed)/feed/RefetchItemsButton.tsx b/src/app/(feed)/feed/RefetchItemsButton.tsx index 2430928..a89c18e 100644 --- a/src/app/(feed)/feed/RefetchItemsButton.tsx +++ b/src/app/(feed)/feed/RefetchItemsButton.tsx @@ -5,16 +5,19 @@ import clsx from "clsx"; import { RefreshCwIcon } from "lucide-react"; import { usePathname } from "next/navigation"; import { ButtonWithShortcut } from "~/components/ButtonWithShortcut"; -import { useFeedItemsQuery } from "~/lib/data/feed-items"; +import { useFetchFeedItems, useFetchFeedItemsStatus } from "~/lib/data/store"; import { useShortcut } from "~/lib/hooks/useShortcut"; export function RefetchItemsButton() { const pathname = usePathname(); const queryClient = useQueryClient(); - const { fetchStatus } = useFeedItemsQuery(); + + const fetchStatus = useFetchFeedItemsStatus(); + const fetchFeedItems = useFetchFeedItems(); useShortcut("r", () => { + fetchFeedItems(); queryClient.invalidateQueries(); }); diff --git a/src/app/(feed)/feed/SidebarCategories.tsx b/src/app/(feed)/feed/SidebarCategories.tsx index e80d462..b501bac 100644 --- a/src/app/(feed)/feed/SidebarCategories.tsx +++ b/src/app/(feed)/feed/SidebarCategories.tsx @@ -16,8 +16,6 @@ import { categoryFilterAtom, dateFilterAtom, feedFilterAtom, - useFeedItemsMap, - useFeedItemsOrder, visibilityFilterAtom, } from "~/lib/data/atoms"; import { useContentCategories } from "~/lib/data/content-categories"; @@ -25,10 +23,11 @@ import { useFeedCategories } from "~/lib/data/feed-categories"; import { doesFeedItemPassFilters } from "~/lib/data/feed-items"; import { useDeselectViewFilter } from "~/lib/data/views"; import { useDialogStore } from "./dialogStore"; +import { useFeedItemsDict, useFeedItemsOrder } from "~/lib/data/store"; function useCheckFilteredFeedItemsForCategory() { const feedItemsOrder = useFeedItemsOrder(); - const feedItemsMap = useFeedItemsMap(); + const feedItemsMap = useFeedItemsDict(); const { feedCategories } = useFeedCategories(); const visibilityFilter = useAtomValue(visibilityFilterAtom); diff --git a/src/app/(feed)/feed/SidebarFeeds.tsx b/src/app/(feed)/feed/SidebarFeeds.tsx index 61f71a7..c0b0caf 100644 --- a/src/app/(feed)/feed/SidebarFeeds.tsx +++ b/src/app/(feed)/feed/SidebarFeeds.tsx @@ -15,8 +15,6 @@ import { categoryFilterAtom, dateFilterAtom, feedFilterAtom, - useFeedItemsMap, - useFeedItemsOrder, viewFilterAtom, visibilityFilterAtom, } from "~/lib/data/atoms"; @@ -25,10 +23,11 @@ import { doesFeedItemPassFilters } from "~/lib/data/feed-items"; import { useFeeds } from "~/lib/data/feeds"; import { useDeselectViewFilter } from "~/lib/data/views"; import { useDialogStore } from "./dialogStore"; +import { useFeedItemsDict, useFeedItemsOrder } from "~/lib/data/store"; function useCheckFilteredFeedItemsForFeed() { const feedItemsOrder = useFeedItemsOrder(); - const feedItemsMap = useFeedItemsMap(); + const feedItemsDict = useFeedItemsDict(); const { feedCategories } = useFeedCategories(); const { feeds } = useFeeds(); @@ -42,9 +41,9 @@ function useCheckFilteredFeedItemsForFeed() { if (!feedItemsOrder || !feedCategories) return []; return feedItemsOrder.filter( (item) => - feedItemsMap[item] && + feedItemsDict[item] && doesFeedItemPassFilters( - feedItemsMap[item], + feedItemsDict[item], dateFilter, visibilityFilter, categoryFilter, @@ -57,7 +56,7 @@ function useCheckFilteredFeedItemsForFeed() { }, [ feedItemsOrder, - feedItemsMap, + feedItemsDict, dateFilter, visibilityFilter, categoryFilter, diff --git a/src/app/(feed)/feed/TodayItems.tsx b/src/app/(feed)/feed/TodayItems.tsx index c233c43..f0ea0d4 100644 --- a/src/app/(feed)/feed/TodayItems.tsx +++ b/src/app/(feed)/feed/TodayItems.tsx @@ -22,15 +22,9 @@ import { CardHeader, CardTitle, } from "~/components/ui/card"; -import { - useFeedItemGlobalState, - useHasFetchedFeedItems, -} from "~/lib/data/atoms"; + import { useFeedCategories } from "~/lib/data/feed-categories"; -import { - useFeedItemsQuery, - useFilteredFeedItemsOrder, -} from "~/lib/data/feed-items"; +import { useFilteredFeedItemsOrder } from "~/lib/data/feed-items"; import { useFeedItemsSetWatchedValueMutation, useFeedItemsSetWatchLaterValueMutation, @@ -39,6 +33,12 @@ import { useFeeds } from "~/lib/data/feeds"; import { useViews } from "~/lib/data/views"; import { useDialogStore } from "./dialogStore"; import { memo } from "react"; +import { + feedItemsStore, + useFeedItemsLastFetchedAt, + useFeedItemValue, + useFetchFeedItemsStatus, +} from "~/lib/data/store"; function timeAgo(date: string | Date) { const diff = dayjs().diff(date); @@ -133,10 +133,13 @@ function TodayItemsFeedEmptyState() { } function LoaderDisplay() { - const hasFetchedFeedItems = useHasFetchedFeedItems(); - const { fetchStatus: feedItemsFetchStatus } = useFeedItemsQuery(); + const feedItemsLastFetchedAt = useFeedItemsLastFetchedAt(); + const feedItemsFetchStatus = useFetchFeedItemsStatus(); - if (feedItemsFetchStatus === "idle" || hasFetchedFeedItems) { + if ( + feedItemsFetchStatus !== "fetching" || + (feedItemsFetchStatus === "fetching" && feedItemsLastFetchedAt !== null) + ) { return null; } @@ -158,13 +161,15 @@ function LoaderDisplay() { function ItemDisplay({ contentId }: { contentId: string }) { const { feeds } = useFeeds(); - const [item] = useFeedItemGlobalState(contentId); + const item = useFeedItemValue(contentId); const { mutateAsync: setWatchedValue } = useFeedItemsSetWatchedValueMutation(contentId); const { mutateAsync: setWatchLaterValue } = useFeedItemsSetWatchLaterValueMutation(contentId); + if (!item) return null; + const feed = feeds.find((f) => f.id === item.feedId); const itemDestination = item.platform === "website" ? "read" : "watch"; @@ -263,7 +268,7 @@ export function TodayItems() { const { feeds, hasFetchedFeeds } = useFeeds(); const { hasFetchedFeedCategories } = useFeedCategories(); const { views } = useViews(); - const hasFetchedFeedItems = useHasFetchedFeedItems(); + const feedItemsLastFetchedAt = useFeedItemsLastFetchedAt(); const filteredFeedItemsOrder = useFilteredFeedItemsOrder(); @@ -279,7 +284,7 @@ export function TodayItems() { if ( hasFetchedFeeds && - hasFetchedFeedItems && + feedItemsLastFetchedAt !== null && hasFetchedFeedCategories && !filteredFeedItemsOrder.length ) { diff --git a/src/app/(feed)/feed/TopRightHeaderContent.tsx b/src/app/(feed)/feed/TopRightHeaderContent.tsx index 9f16abc..4c95db5 100644 --- a/src/app/(feed)/feed/TopRightHeaderContent.tsx +++ b/src/app/(feed)/feed/TopRightHeaderContent.tsx @@ -8,25 +8,25 @@ import { } from "lucide-react"; import Link from "next/link"; import { usePathname } from "next/navigation"; +import { useState } from "react"; +import { EditFeedDialog } from "~/components/AddFeedDialog"; import { ButtonWithShortcut } from "~/components/ButtonWithShortcut"; import { CustomVideoButton } from "~/components/CustomVideoButton"; import { Button } from "~/components/ui/button"; import { useSidebar } from "~/components/ui/sidebar"; -import { useFeedItemGlobalState } from "~/lib/data/atoms"; +import { PLATFORM_TO_FORMATTED_NAME_MAP } from "~/lib/data/feeds/utils"; +import { useFeedItemValue } from "~/lib/data/store"; import { useShortcut } from "~/lib/hooks/useShortcut"; import { OpenRightSidebarButton } from "./OpenRightSidebarButton"; import { RefetchItemsButton } from "./RefetchItemsButton"; import { MAX_ZOOM, MIN_ZOOM, useZoom } from "./watch/[id]/useZoom"; -import { useState } from "react"; -import { EditFeedDialog } from "~/components/AddFeedDialog"; -import { PLATFORM_TO_FORMATTED_NAME_MAP } from "~/lib/data/feeds/utils"; function OpenInYouTubeButton() { const pathname = usePathname(); const videoId = pathname.split("/feed/watch/")[1]!; const contentId = pathname.split("/feed/read/")[1]!; - const [feedItem] = useFeedItemGlobalState(videoId || contentId || ""); + const feedItem = useFeedItemValue(videoId || contentId || ""); // If not a Serial item, assume YouTube if (!feedItem) { @@ -61,7 +61,7 @@ function EditFeedButton() { const videoId = pathname.split("/feed/watch/")[1]!; const contentId = pathname.split("/feed/read/")[1]!; - const [feedItem] = useFeedItemGlobalState(videoId || contentId || ""); + const feedItem = useFeedItemValue(videoId || contentId || ""); const [selectedFeedForEditing, setSelectedFeedForEditing] = useState< null | number diff --git a/src/app/(feed)/feed/read/[id]/page.tsx b/src/app/(feed)/feed/read/[id]/page.tsx index e28d469..60a9c43 100644 --- a/src/app/(feed)/feed/read/[id]/page.tsx +++ b/src/app/(feed)/feed/read/[id]/page.tsx @@ -3,17 +3,17 @@ import clsx from "clsx"; import { use } from "react"; -import { useFeedItemGlobalState } from "~/lib/data/atoms"; import { useZoom } from "../../watch/[id]/useZoom"; -import classes from "./article.module.css"; -import { useFeeds } from "~/lib/data/feeds"; -import { unified } from "unified"; import rehypeParse from "rehype-parse"; import rehypeSanitize from "rehype-sanitize"; import rehypeStringify from "rehype-stringify"; +import { unified } from "unified"; +import { useFeeds } from "~/lib/data/feeds"; +import { useFeedItemValue } from "~/lib/data/store"; import { useFlagState } from "~/lib/hooks/useFlagState"; import { ContentActions } from "../../watch/[id]/ContentActions"; +import classes from "./article.module.css"; const parser = unified() .use(rehypeParse, { fragment: true }) @@ -34,7 +34,7 @@ export default function ReadPage(props: { params: Promise<{ id: string }> }) { const [articleStyle] = useFlagState("ARTICLE_STYLE"); const params = use(props.params); - const [feedItem] = useFeedItemGlobalState(params?.id ?? ""); + const feedItem = useFeedItemValue(params?.id ?? ""); const { feeds } = useFeeds(); const feed = feeds.find((f) => f.id === feedItem?.feedId); diff --git a/src/app/(feed)/feed/watch/[id]/ContentActions.tsx b/src/app/(feed)/feed/watch/[id]/ContentActions.tsx index 9a787d6..422b94c 100644 --- a/src/app/(feed)/feed/watch/[id]/ContentActions.tsx +++ b/src/app/(feed)/feed/watch/[id]/ContentActions.tsx @@ -1,7 +1,6 @@ import { CheckIcon, ClockIcon, EyeIcon, EyeOffIcon } from "lucide-react"; import { ButtonWithShortcut } from "~/components/ButtonWithShortcut"; import { Button } from "~/components/ui/button"; -import { useFeedItemGlobalState } from "~/lib/data/atoms"; import { useFeedItemsSetWatchedValueMutation, useFeedItemsSetWatchLaterValueMutation, @@ -9,11 +8,12 @@ import { import { useMediaQuery } from "~/lib/hooks/use-media-query"; import { useView } from "./useView"; import { useShortcut } from "~/lib/hooks/useShortcut"; +import { useFeedItemValue } from "~/lib/data/store"; export function ContentActions({ contentID }: { contentID: string }) { const { view } = useView(); - const [video] = useFeedItemGlobalState(contentID); + const video = useFeedItemValue(contentID); const { mutateAsync: setWatchedValue } = useFeedItemsSetWatchedValueMutation(contentID); diff --git a/src/app/(feed)/feed/watch/[id]/VideoDisplay.tsx b/src/app/(feed)/feed/watch/[id]/VideoDisplay.tsx index 1598608..a412693 100644 --- a/src/app/(feed)/feed/watch/[id]/VideoDisplay.tsx +++ b/src/app/(feed)/feed/watch/[id]/VideoDisplay.tsx @@ -1,11 +1,11 @@ import clsx from "clsx"; import { useEffect, useState } from "react"; import ResponsiveVideo from "~/components/ResponsiveVideo"; +import { useFeedItemValue } from "~/lib/data/store"; import { useShortcut } from "~/lib/hooks/useShortcut"; -import { useView } from "./useView"; import { ContentActions } from "./ContentActions"; import { useVideoNavigationShortcuts } from "./useVideoNavigationShortcuts"; -import { useFeedItemGlobalState } from "~/lib/data/atoms"; +import { useView } from "./useView"; export function VideoDisplay({ id, @@ -14,7 +14,7 @@ export function VideoDisplay({ id: string; isInactive: boolean; }) { - const [item] = useFeedItemGlobalState(id); + const item = useFeedItemValue(id); const [showVideo, setShowVideo] = useState(false); const { view, toggleView } = useView(); @@ -34,6 +34,8 @@ export function VideoDisplay({ }; }, []); + if (!item) return null; + return ( <>
items[id]?.feedId).filter(Boolean); + const feedIdsInOrder = order + .map((id) => itemsDict[id]?.feedId) + .filter(Boolean); const orderSet = new Set(feedIdsInOrder); const foundFeeds: DatabaseFeed[] = []; diff --git a/src/components/ResponsiveVideo.tsx b/src/components/ResponsiveVideo.tsx index eff8d4c..824c9a7 100644 --- a/src/components/ResponsiveVideo.tsx +++ b/src/components/ResponsiveVideo.tsx @@ -2,10 +2,10 @@ import clsx from "clsx"; import { useRef } from "react"; -import { useFeedItemGlobalState } from "~/lib/data/atoms"; import { useFlagState } from "~/lib/hooks/useFlagState"; import { CustomVideoPlayer } from "./CustomVideoPlayer"; import classes from "./ResponsiveVideo.module.css"; +import { useFeedItemValue } from "~/lib/data/store"; interface IResponsiveVideoProps { videoID?: string; @@ -35,8 +35,8 @@ function YouTubeEmbed(props: IEmbedProps) { } function PeerTubeEmbed(props: IEmbedProps) { - const [feedItem] = useFeedItemGlobalState(props?.videoID ?? ""); - const baseUrl = feedItem.url.split("/w/")[0]; + const feedItem = useFeedItemValue(props?.videoID ?? ""); + const baseUrl = feedItem?.url.split("/w/")[0]; return ( <> @@ -61,7 +61,7 @@ export default function ResponsiveVideo(props: IResponsiveVideoProps) { const containerRef = useRef(null); const [videoPlayer] = useFlagState("CUSTOM_VIDEO_PLAYER"); - const [feedItem] = useFeedItemGlobalState(props?.videoID ?? ""); + const feedItem = useFeedItemValue(props?.videoID ?? ""); const feedItemPlatform = feedItem?.platform ?? "youtube"; diff --git a/src/lib/data/InitialClientQueries.tsx b/src/lib/data/InitialClientQueries.tsx index 24007bf..e010467 100644 --- a/src/lib/data/InitialClientQueries.tsx +++ b/src/lib/data/InitialClientQueries.tsx @@ -4,13 +4,18 @@ import { useAtom } from "jotai"; import { useEffect, type PropsWithChildren } from "react"; import { hasSetInitialViewAtom } from "./atoms"; import { useContentCategoriesQuery } from "./content-categories"; -import { useFeedItemsQuery } from "./feed-items"; import { useFeedsQuery } from "./feeds"; import { useUpdateViewFilter, useViews } from "./views"; +import { useFetchFeedItems } from "./store"; export function InitialClientQueries({ children }: PropsWithChildren) { + const fetchFeedItems = useFetchFeedItems(); + + useEffect(() => { + fetchFeedItems(); + }, []); + useFeedsQuery(); - useFeedItemsQuery(); useContentCategoriesQuery(); const [hasSetInitialView, setHasSetInitialView] = useAtom( diff --git a/src/lib/data/atoms.ts b/src/lib/data/atoms.ts index 0e413ac..9289b98 100644 --- a/src/lib/data/atoms.ts +++ b/src/lib/data/atoms.ts @@ -9,33 +9,12 @@ import type { DatabaseContentCategory, DatabaseFeedCategory, } from "~/server/db/schema"; +import { feedItemsStore } from "./store"; export const hasFetchedFeedsAtom = atom(false); export const feedsAtom = atom([]); -export const hasFetchedFeedItemsAtom = atom(false); -export const useHasFetchedFeedItems = () => - useAtomValue(hasFetchedFeedItemsAtom); - export const feedItemsOrderAtom = atom([]); -export const useFeedItemsOrder = () => useAtomValue(feedItemsOrderAtom); - -export const feedItemsMapAtom = atom>({}); -export const useFeedItemsMap = () => useAtomValue(feedItemsMapAtom); - -export function useFeedItemAtom(contentId: string) { - return useMemo(() => { - return focusAtom(feedItemsMapAtom, (optic) => optic.prop(contentId)); - }, [contentId]); -} -export type FeedItemAtom = ReturnType; - -export function useFeedItemGlobalState(contentId: string) { - const feedItemAtom = useFeedItemAtom(contentId); - return useAtom(feedItemAtom); -} - -export type FeedItemStateSetter = ReturnType[1]; export const hasFetchedContentCategoriesAtom = atom(false); export const contentCategoriesAtom = atom([]); @@ -69,14 +48,14 @@ export const viewFilterAtom = atom((get) => { export const useClearAllUserData = () => { const setFeedsAtom = useSetAtom(feedsAtom); - const setFeedItemsMapAtom = useSetAtom(feedItemsMapAtom); + const resetFeedItems = feedItemsStore.useReset(); const setContentCategoriesAtom = useSetAtom(contentCategoriesAtom); const setFeedCategoriesAtom = useSetAtom(feedCategoriesAtom); const setViewsAtom = useSetAtom(viewsAtom); return () => { setFeedsAtom([]); - setFeedItemsMapAtom({}); + resetFeedItems(); setContentCategoriesAtom([]); setFeedCategoriesAtom([]); setViewsAtom([]); diff --git a/src/lib/data/createSelectorHooks.ts b/src/lib/data/createSelectorHooks.ts new file mode 100644 index 0000000..98c6ba9 --- /dev/null +++ b/src/lib/data/createSelectorHooks.ts @@ -0,0 +1,27 @@ +import { type StoreApi, type UseBoundStore, useStore } from "zustand"; +import { useShallow } from "zustand/react/shallow"; + +export type ZustandHookSelectors = { + [Key in NonNullable as `use${Capitalize< + string & Key + >}`]: () => StateType[Key]; +}; + +const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1); + +export function createSelectorHooks( + store: UseBoundStore> | StoreApi, +) { + const storeIn = store as any; + + Object.keys(storeIn.getState()).forEach((key) => { + const selector = (state: StateType) => state[key as keyof StateType]; + storeIn[`use${capitalize(key)}`] = + typeof storeIn === "function" + ? () => storeIn(useShallow(selector)) + : () => useStore(storeIn, useShallow(selector as any)); + }); + + return storeIn as UseBoundStore> & + ZustandHookSelectors; +} diff --git a/src/lib/data/feed-items/index.ts b/src/lib/data/feed-items/index.ts index b9eae33..9a238ae 100644 --- a/src/lib/data/feed-items/index.ts +++ b/src/lib/data/feed-items/index.ts @@ -1,8 +1,4 @@ -import { useQuery } from "@tanstack/react-query"; -import { atom, useAtomValue, useSetAtom } from "jotai"; -import { useEffect, useRef } from "react"; -import { assembleIteratorResult } from "~/lib/iterators"; -import { orpc } from "~/lib/orpc"; +import { atom, useAtomValue } from "jotai"; import type { ApplicationFeedItem, ApplicationView, @@ -14,14 +10,12 @@ import { dateFilterAtom, feedCategoriesAtom, feedFilterAtom, - feedItemsMapAtom, - feedItemsOrderAtom, feedsAtom, - hasFetchedFeedItemsAtom, viewFilterAtom, type VisibilityFilter, visibilityFilterAtom, } from "../atoms"; +import { feedItemsStore } from "../store"; import { INBOX_VIEW_ID } from "../views"; export function doesFeedItemPassFilters( @@ -100,22 +94,22 @@ export function doesFeedItemPassFilters( return true; } -const filteredFeedItemsOrderAtom = atom((get) => { - const dateFilter = get(dateFilterAtom); - const visibilityFilter = get(visibilityFilterAtom); - const categoryFilter = get(categoryFilterAtom); - const feedItemsOrder = get(feedItemsOrderAtom); - const feedItemsMap = get(feedItemsMapAtom); - const feedCategories = get(feedCategoriesAtom); - const feedFilter = get(feedFilterAtom); - const feeds = get(feedsAtom); - const viewFilter = get(viewFilterAtom); +export const useFilteredFeedItemsOrder = () => { + const dateFilter = useAtomValue(dateFilterAtom); + const visibilityFilter = useAtomValue(visibilityFilterAtom); + const categoryFilter = useAtomValue(categoryFilterAtom); + const feedItemsOrder = feedItemsStore.useFeedItemsOrder(); + const feedItemsDict = feedItemsStore.useFeedItemsDict(); + const feedCategories = useAtomValue(feedCategoriesAtom); + const feedFilter = useAtomValue(feedFilterAtom); + const feeds = useAtomValue(feedsAtom); + const viewFilter = useAtomValue(viewFilterAtom); - return feedItemsOrder.filter( - (item) => - feedItemsMap[item] && + return feedItemsOrder.filter((id) => { + return ( + feedItemsDict[id] && doesFeedItemPassFilters( - feedItemsMap[item], + feedItemsDict[id], dateFilter, visibilityFilter, categoryFilter, @@ -123,9 +117,10 @@ const filteredFeedItemsOrderAtom = atom((get) => { feedFilter, feeds, viewFilter, - ), - ); -}); + ) + ); + }); +}; export function useDoesFeedItemMatchAllFilters(item: ApplicationFeedItem) { const dateFilter = useAtomValue(dateFilterAtom); @@ -147,72 +142,3 @@ export function useDoesFeedItemMatchAllFilters(item: ApplicationFeedItem) { viewFilter, ); } -export const useFilteredFeedItemsOrder = () => - useAtomValue(filteredFeedItemsOrderAtom); - -const ONE_HOUR = 1000 * 60 * 60; - -export function useFeedItemsQuery() { - const setHasFetchedFeedItems = useSetAtom(hasFetchedFeedItemsAtom); - const setFeedItemsOrder = useSetAtom(feedItemsOrderAtom); - const setFeedItemsMap = useSetAtom(feedItemsMapAtom); - - const hasUpdatedBasedOnQueryRef = useRef(false); - const query = useQuery( - orpc.feedItem.getAll.experimental_streamedOptions({ - staleTime: ONE_HOUR, - }), - ); - - useEffect(() => { - if (query.fetchStatus === "fetching") { - const data = assembleIteratorResult(query.data ?? []).sort((a, b) => { - if (a.postedAt <= b.postedAt) return 1; - return -1; - }); - - setFeedItemsOrder((prevItemsOrder) => - data.reduce((acc, item) => { - if (acc.find((id) => id === item.id)) { - return acc; - } - acc.push(item.id); - return acc; - }, prevItemsOrder), - ); - setFeedItemsMap((prevItemsMap) => - data.reduce((acc, item) => ({ ...acc, [item.id]: item }), prevItemsMap), - ); - } else if ( - query.isSuccess && - query.fetchStatus === "idle" && - hasUpdatedBasedOnQueryRef.current === false - ) { - const data = assembleIteratorResult(query.data).sort((a, b) => { - if (a.postedAt <= b.postedAt) return 1; - return -1; - }); - - hasUpdatedBasedOnQueryRef.current = true; - setHasFetchedFeedItems(true); - setFeedItemsOrder(data.map((item) => item.id)); - setFeedItemsMap( - data.reduce((acc, item) => ({ ...acc, [item.id]: item }), {}), - ); - } else if (query.isFetching) { - hasUpdatedBasedOnQueryRef.current = false; - } - }, [ - query.isSuccess, - query.isFetching, - query.fetchStatus, - query.data, - setFeedItemsOrder, - setFeedItemsMap, - setHasFetchedFeedItems, - ]); - - return query; -} - -export const FETCH_NEW_FEED_ITEMS_KEY = "fetch-items-on-mount"; diff --git a/src/lib/data/feed-items/mutations.ts b/src/lib/data/feed-items/mutations.ts index 083d75e..ff5dc48 100644 --- a/src/lib/data/feed-items/mutations.ts +++ b/src/lib/data/feed-items/mutations.ts @@ -1,16 +1,16 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useTRPC } from "~/trpc/react"; -import { useFeedItemGlobalState } from "../atoms"; import { orpc } from "~/lib/orpc"; +import { useFeedItemState } from "../store"; export function useFeedItemsSetWatchedValueMutation(contentId: string) { - const [feedItem, setFeedItem] = useFeedItemGlobalState(contentId); + const [feedItem, setFeedItem] = useFeedItemState(contentId); // We're not refetching on success here, as the frequency of // toggling this value makes it very wasteful return useMutation( orpc.feedItem.setWatchedValue.mutationOptions({ onMutate: ({ isWatched }) => { + if (!feedItem) return; setFeedItem({ ...feedItem, isWatched, @@ -21,13 +21,14 @@ export function useFeedItemsSetWatchedValueMutation(contentId: string) { } export function useFeedItemsSetWatchLaterValueMutation(contentId: string) { - const [feedItem, setFeedItem] = useFeedItemGlobalState(contentId); + const [feedItem, setFeedItem] = useFeedItemState(contentId); // We're not refetching on success here, as the frequency of // toggling this value makes it very wasteful return useMutation( orpc.feedItem.setWatchLaterValue.mutationOptions({ onMutate: ({ isWatchLater }) => { + if (!feedItem) return; setFeedItem({ ...feedItem, isWatchLater, diff --git a/src/lib/data/feeds/mutations.ts b/src/lib/data/feeds/mutations.ts index a0082c4..4a3bcc3 100644 --- a/src/lib/data/feeds/mutations.ts +++ b/src/lib/data/feeds/mutations.ts @@ -1,13 +1,18 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useTRPC } from "~/trpc/react"; import { useFeeds } from "."; -import { useAtom } from "jotai"; -import { feedItemsMapAtom, feedItemsOrderAtom } from "../atoms"; import { orpc } from "~/lib/orpc"; +import { + feedItemsStore, + useFeedItemsDict, + useFeedItemsOrder, + useFetchFeedItems, +} from "../store"; export function useCreateFeedMutation() { const api = useTRPC(); const queryClient = useQueryClient(); + const refetchFeedItems = useFetchFeedItems(); return useMutation( orpc.feed.create.mutationOptions({ @@ -15,9 +20,7 @@ export function useCreateFeedMutation() { await queryClient.invalidateQueries({ queryKey: orpc.feed.getAll.queryKey(), }); - await queryClient.invalidateQueries({ - queryKey: orpc.feedItem.getAll.queryKey(), - }); + refetchFeedItems(); await queryClient.invalidateQueries({ queryKey: api.feedCategories.getAll.queryKey(), }); @@ -29,6 +32,7 @@ export function useCreateFeedMutation() { export function useCreateFeedsFromSubscriptionImportMutation() { const api = useTRPC(); const queryClient = useQueryClient(); + const refetchFeedItems = useFetchFeedItems(); return useMutation( orpc.feed.createFromSubscriptionImport.mutationOptions({ @@ -36,9 +40,7 @@ export function useCreateFeedsFromSubscriptionImportMutation() { await queryClient.invalidateQueries({ queryKey: orpc.feed.getAll.queryKey(), }); - await queryClient.invalidateQueries({ - queryKey: orpc.feedItem.getAll.queryKey(), - }); + refetchFeedItems(); await queryClient.invalidateQueries({ queryKey: api.feedCategories.getAll.queryKey(), }); @@ -51,8 +53,14 @@ export function useDeleteFeedMutation() { const queryClient = useQueryClient(); const { feeds, setFeeds } = useFeeds(); - const [feedItemsOrder, setFeedItemsOrder] = useAtom(feedItemsOrderAtom); - const [feedItemsMap, setFeedItemsMap] = useAtom(feedItemsMapAtom); + + const feedItemsOrder = useFeedItemsOrder(); + const feedItemsDict = useFeedItemsDict(); + + const setFeedItemsOrder = feedItemsStore.useSetFeedItemsOrder(); + const setFeedItemsDict = feedItemsStore.useSetFeedItemsDict(); + + const refetchFeedItems = useFetchFeedItems(); return useMutation( orpc.feed.delete.mutationOptions({ @@ -61,7 +69,7 @@ export function useDeleteFeedMutation() { const [updatedFeedItemsOrder, removedFeedItems] = feedItemsOrder.reduce( ([partialKeptItems, partialRemovedItems], feedItemContentId) => { - if (feedItemsMap[feedItemContentId]?.feedId === feedId) { + if (feedItemsDict[feedItemContentId]?.feedId === feedId) { partialRemovedItems.push(feedItemContentId); } else { partialKeptItems.push(feedItemContentId); @@ -72,23 +80,21 @@ export function useDeleteFeedMutation() { [[], []] as [string[], string[]], ); - const updatedFeedItemsMap = removedFeedItems.reduce( + const updatedfeedItemsDict = removedFeedItems.reduce( (partialMap, feedItemContentId) => { delete partialMap[feedItemContentId]; return partialMap; }, - { ...feedItemsMap }, + { ...feedItemsDict }, ); setFeedItemsOrder(updatedFeedItemsOrder); - setFeedItemsMap(updatedFeedItemsMap); + setFeedItemsDict(updatedfeedItemsDict); await queryClient.invalidateQueries({ queryKey: orpc.feed.getAll.queryKey(), }); - await queryClient.invalidateQueries({ - queryKey: orpc.feedItem.getAll.queryKey(), - }); + refetchFeedItems(); }, }), ); diff --git a/src/lib/data/store.ts b/src/lib/data/store.ts new file mode 100644 index 0000000..52ab51b --- /dev/null +++ b/src/lib/data/store.ts @@ -0,0 +1,134 @@ +import { createStore, useStore } from "zustand"; +import { useShallow } from "zustand/react/shallow"; +import { ApplicationFeedItem } from "~/server/db/schema"; +import { orpcRouterClient } from "../orpc"; +import { createSelectorHooks } from "./createSelectorHooks"; + +type ApplicationStore = { + reset: () => void; + feedItemsOrder: string[]; + setFeedItemsOrder: (itemsOrder: string[]) => void; + feedItemsDict: Record; + setFeedItemsDict: (itemsDict: Record) => void; + setFeedItem: (id: string, item: ApplicationFeedItem) => void; + fetchFeedItems: () => Promise; + fetchFeedItemsLastFetchedAt: number | null; + fetchFeedItemsStatus: "idle" | "fetching" | "success"; +}; + +const vanillaApplicationStore = createStore()( + // persist( + (set, get) => ({ + reset: () => + set({ + feedItemsOrder: [], + feedItemsDict: {}, + fetchFeedItemsLastFetchedAt: null, + fetchFeedItemsStatus: "idle", + }), + feedItemsOrder: [], + setFeedItemsOrder: (itemsOrder) => set({ feedItemsOrder: itemsOrder }), + feedItemsDict: {}, + setFeedItemsDict: (itemsDict) => set({ feedItemsDict: itemsDict }), + setFeedItem: (id, item) => + set({ + feedItemsDict: { + ...get().feedItemsDict, + [id]: item, + }, + }), + fetchFeedItemsLastFetchedAt: null, + fetchFeedItemsStatus: "idle", + + fetchFeedItems: async () => { + if (get().fetchFeedItemsStatus === "fetching") return; + + set({ + fetchFeedItemsStatus: "fetching", + }); + + let lastUpdateTime = 0; + for await (const incomingFeedItems of await orpcRouterClient.feedItem.getAll()) { + // const DEBOUNCE_TIME = 50; + // const timeSinceLastUpdate = Date.now() - lastUpdateTime; + // const timeToWait = DEBOUNCE_TIME - timeSinceLastUpdate; + + // if (timeToWait > 0) { + // await new Promise((res) => setTimeout(res, timeToWait)); + // } + + // console.log("updating"); + + // TODO: create date sorting + const { updatedItemsDict, updatedItemsOrder } = + incomingFeedItems.reduce( + ({ updatedItemsDict, updatedItemsOrder }, item) => { + updatedItemsDict[item.id] = item; + + if (!updatedItemsOrder.find((id) => id === item.id)) { + updatedItemsOrder.push(item.id); + } + + return { + updatedItemsDict, + updatedItemsOrder, + }; + }, + { + updatedItemsDict: { ...get().feedItemsDict }, + updatedItemsOrder: [...get().feedItemsOrder], + }, + ); + + set({ + feedItemsDict: updatedItemsDict, + feedItemsOrder: updatedItemsOrder, + }); + lastUpdateTime = Date.now(); + } + set({ + fetchFeedItemsStatus: "success", + fetchFeedItemsLastFetchedAt: Date.now(), + }); + }, + }), + // { + // name: "serial", // name of the item in the storage (must be unique) + // version: 0, + // partialize: (state) => ({ + // itemsOrder: state.feedItemsOrder, + // itemsDict: state.feedItemsDict, + // fetchItemsLastFetchedAt: state.fetchFeedItemsLastFetchedAt, + // }), + // }, + // ), +); + +export const feedItemsStore = createSelectorHooks(vanillaApplicationStore); + +export const useFeedItemsDict = feedItemsStore.useFeedItemsDict; +export const useFeedItemsOrder = feedItemsStore.useFeedItemsOrder; + +export const useFeedItemsLastFetchedAt = + feedItemsStore.useFetchFeedItemsLastFetchedAt; +export const useFetchFeedItemsStatus = feedItemsStore.useFetchFeedItemsStatus; +export const useFetchFeedItems = feedItemsStore.useFetchFeedItems; + +export const useFeedItemValue = (id: string) => { + return useStore( + feedItemsStore, + useShallow((store) => store.feedItemsDict?.[id]), + ); +}; +export const useSetFeedItemValue = (id: string) => { + const setter = useStore(feedItemsStore, (store) => store.setFeedItem); + + return (item: ApplicationFeedItem) => setter(id, item); +}; + +export const useFeedItemState = (id: string) => { + const value = useFeedItemValue(id); + const setValue = useSetFeedItemValue(id); + + return [value, setValue] as const; +}; diff --git a/src/lib/data/views/index.ts b/src/lib/data/views/index.ts index 21563a0..af6d80f 100644 --- a/src/lib/data/views/index.ts +++ b/src/lib/data/views/index.ts @@ -11,8 +11,6 @@ import { feedFilterAtom, hasFetchedViewsAtom, UNSELECTED_VIEW_ID, - useFeedItemsMap, - useFeedItemsOrder, viewFilterIdAtom, viewsAtom, } from "../atoms"; @@ -21,6 +19,7 @@ import { useFeedCategories } from "../feed-categories"; import { doesFeedItemPassFilters } from "../feed-items"; import { useFeeds } from "../feeds"; import { sortViewsByPlacement } from "./utils"; +import { useFeedItemsDict, useFeedItemsOrder } from "../store"; export const INBOX_VIEW_ID = -1; export const INBOX_VIEW_PLACEMENT = -1; @@ -60,7 +59,7 @@ export function useUpdateViewFilter() { export function useCheckFilteredFeedItemsForView() { const feedItemsOrder = useFeedItemsOrder(); - const feedItemsMap = useFeedItemsMap(); + const feedItemsDict = useFeedItemsDict(); const { feedCategories } = useFeedCategories(); const { feeds } = useFeeds(); const { views } = useViews(); @@ -72,9 +71,9 @@ export function useCheckFilteredFeedItemsForView() { return feedItemsOrder.filter( (item) => - feedItemsMap[item] && + feedItemsDict[item] && doesFeedItemPassFilters( - feedItemsMap[item], + feedItemsDict[item], viewFilter?.daysWindow ?? 1, "unread", -1, @@ -85,7 +84,7 @@ export function useCheckFilteredFeedItemsForView() { ), ); }, - [feedItemsOrder, feedItemsMap, feedCategories, feeds, views], + [feedItemsOrder, feedItemsDict, feedCategories, feeds, views], ); } diff --git a/src/server/api/routers/feedItemRouter.ts b/src/server/api/routers/feedItemRouter.ts index d25b614..6804d6c 100644 --- a/src/server/api/routers/feedItemRouter.ts +++ b/src/server/api/routers/feedItemRouter.ts @@ -39,6 +39,9 @@ export const getAll = protectedProcedure.handler(async function* ({ context }) { } // Get new items, yield + + // TODO: split this out such that we can return data from + // each feed as it comes in const feedData = await fetchFeedData(feedsList); if (!feedData) { return;