diff --git a/.zed/debug.json b/.zed/debug.json new file mode 100644 index 00000000..4be1a903 --- /dev/null +++ b/.zed/debug.json @@ -0,0 +1,5 @@ +// Project-local debug tasks +// +// For more documentation on how to configure debug tasks, +// see: https://zed.dev/docs/debugger +[] diff --git a/next.config.ts b/next.config.ts index 447188e1..2486940f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -14,9 +14,7 @@ const nextConfig: NextConfig = { }, reactCompiler: true, - cacheComponents: true, - experimental: { // Forward browser logs to the terminal for easier debugging browserDebugInfoInTerminal: true, diff --git a/src/app/[locale]/(app)/d/(sidebar)/feeds/layout.tsx b/src/app/[locale]/(app)/d/(sidebar)/feeds/layout.tsx index ad0c9bfa..5690edd6 100644 --- a/src/app/[locale]/(app)/d/(sidebar)/feeds/layout.tsx +++ b/src/app/[locale]/(app)/d/(sidebar)/feeds/layout.tsx @@ -1,6 +1,7 @@ import { AppNavigationMenu } from "@/app/[locale]/ui/app-navigation-menu"; import { FeedsSidebar } from "@/app/[locale]/ui/feeds/sidebar/feeds-sidebar"; import { SidebarFeedsProvider } from "@/components/ui/sidebar"; +import FeedsUnreadCountProvider from "@/lib/stores/feeds-read-count-context"; export default function DashboardLayout({ children, @@ -9,11 +10,13 @@ export default function DashboardLayout({ }): React.JSX.Element { return ( - -
- -
{children}
-
+ + +
+ +
{children}
+
+
); } diff --git a/src/app/[locale]/ui/feeds/feeds-timeline.tsx b/src/app/[locale]/ui/feeds/feeds-timeline.tsx index e3a15675..9b8ab845 100644 --- a/src/app/[locale]/ui/feeds/feeds-timeline.tsx +++ b/src/app/[locale]/ui/feeds/feeds-timeline.tsx @@ -12,6 +12,7 @@ import { Link } from "@/i18n/routing"; import { markFeedContentAsRead, markFeedContentAsUnread } from "@/lib/actions"; import type { UserPreferences } from "@/lib/constants"; import { parsing } from "@/lib/parsing.client"; +import { useFeedsUnereadCount } from "@/lib/stores/feeds-read-count-context"; import { searchParamsState } from "@/lib/stores/search-params-states"; import { userfeedsfuncs } from "@/lib/userfeeds-funcs"; import { cn } from "@/lib/utils"; @@ -29,6 +30,16 @@ export function FeedsTimeline({ urlKeys: searchParamsState.urlKeys, }); + const feedUnreadCounts: Map = new Map(); + for (const item of timeline) { + if (item.readAt === null) { + feedUnreadCounts.set( + item.feedId, + (feedUnreadCounts.get(item.feedId) ?? 0) + 1, + ); + } + } + const items = timeline .filter((el) => { if (selectedFeed === searchParamsState.DEFAULT_FEED) return true; @@ -44,40 +55,78 @@ export function FeedsTimeline({ return (
{items.map((item) => { - return ; + return ( + + ); })}
); } -const Item = ({ item }: { item: FeedTimeline }): React.JSX.Element => { +const Item = ({ + item, + feedUnreadCounts, +}: { + item: FeedTimeline; + /** + * feedUnreadCounts is the initial number of items left to read in a feed. + * Used for optimistic updates to sync with the sidebar through the context. + */ + feedUnreadCounts: number; +}): React.JSX.Element => { const t = useTranslations("rssFeed"); const locale = useLocale(); const [isRead, setIsRead] = useOptimistic(item.readAt !== null); + const feedsReadCount = useFeedsUnereadCount(); const handleMarkAsRead = async ( feedId: number, feedContentId: number, ): Promise => { + feedsReadCount.setOptimisticUnread( + feedId, + feedUnreadCounts - 1, + feedUnreadCounts, + ); + startTransition(async () => { try { setIsRead(true); const res = await markFeedContentAsRead(feedId, feedContentId); if (res.errors) { + feedsReadCount.setOptimisticUnread( + feedId, + feedUnreadCounts + 1, + feedUnreadCounts, + ); setIsRead(false); toast.error([res.errors.feedId, res.errors.feedContentId].join(", ")); return; } if (res.errI18Key) { + feedsReadCount.setOptimisticUnread( + feedId, + feedUnreadCounts + 1, + feedUnreadCounts, + ); setIsRead(false); // biome-ignore lint/suspicious/noExplicitAny: valid type. toast.error(t(res.errI18Key as any)); return; } } catch (err) { + feedsReadCount.setOptimisticUnread( + feedId, + feedUnreadCounts + 1, + feedUnreadCounts, + ); setIsRead(false); if (err instanceof Error) { toast.error(err.message); @@ -92,24 +141,45 @@ const Item = ({ item }: { item: FeedTimeline }): React.JSX.Element => { feedId: number, feedContentId: number, ): Promise => { + feedsReadCount.setOptimisticUnread( + feedId, + feedUnreadCounts + 1, + feedUnreadCounts, + ); startTransition(async () => { try { setIsRead(false); const res = await markFeedContentAsUnread(feedId, feedContentId); + // Prevent double subtraction. if (res.errors) { + feedsReadCount.setOptimisticUnread( + feedId, + feedUnreadCounts - 1, + feedUnreadCounts, + ); setIsRead(true); toast.error([res.errors.feedId, res.errors.feedContentId].join(", ")); return; } if (res.errI18Key) { + feedsReadCount.setOptimisticUnread( + feedId, + feedUnreadCounts - 1, + feedUnreadCounts, + ); setIsRead(true); // biome-ignore lint/suspicious/noExplicitAny: valid type. toast.error(t(res.errI18Key as any)); return; } } catch (err) { + feedsReadCount.setOptimisticUnread( + feedId, + feedUnreadCounts - 1, + feedUnreadCounts, + ); setIsRead(true); if (err instanceof Error) { toast.error(err.message); diff --git a/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx b/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx index 99547ce4..e864cb46 100644 --- a/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx +++ b/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx @@ -2,7 +2,7 @@ import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"; import { DragDropProvider, PointerSensor, useDroppable } from "@dnd-kit/react"; import { useTranslations } from "next-intl"; -import { startTransition, use, useOptimistic, useRef } from "react"; +import { startTransition, use, useOptimistic } from "react"; import { toast } from "sonner"; import { FeedsSidebarFolder } from "@/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder"; import { FeedsSidebarItem } from "@/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item"; @@ -21,6 +21,7 @@ import { type FeedWithContentsCount, UNCATEGORIZED_FEEDS_FOLDER_ID, } from "@/lib/constants"; +import { useFeedsUnereadCount } from "@/lib/stores/feeds-read-count-context"; type Props = { userFeedsGroupedByFolderPromise: Promise; @@ -34,7 +35,6 @@ export function FeedsSidebarContent({ FeedFolder[] >(_userFeedsGroupedByFolder); // Sauvegarder les éléments au cas si optimistic delete ne fonctionne pas. - const rollbackSnapshotRef = useRef(null); const t = useTranslations("rssFeed"); const handleOnMove = ( @@ -81,9 +81,6 @@ export function FeedsSidebarContent({ // Trouver le dossier orignal, puis mettre id par défaut (-1). setUserFeedsGroupedByFolder((prev) => { const folders: FeedFolder[] = structuredClone(prev); - // Sauvegarder les éléments au cas si optimistic delete ne fonctionne pas. - rollbackSnapshotRef.current = folders; - // Trouver le dossier. const deletedFolder = folders.find((item) => item.folderId === id); if (!deletedFolder) return prev; @@ -159,7 +156,7 @@ export function FeedsSidebarContent({ } // This needs to be it's own component so it can be a droppable zone. -// The drop mechanism wouldn't work if it's a direct descendant of the parent component. +// The drop mechanism wouldn't work if it's directly code in the parent component. function Content({ userFeedsGroupedByFolder, handleOnRemove, @@ -168,17 +165,22 @@ function Content({ handleOnRemove: (id: number) => void; }) { const t = useTranslations("rssFeed"); + const feedsReadCount = useFeedsUnereadCount(); const totalFeeds = userFeedsGroupedByFolder.values().reduce((acc, folder) => { return acc + folder.feeds.length; }, 0); - const totalFeedsContents = userFeedsGroupedByFolder - .values() - .reduce( - (acc, folder) => - acc + folder.feeds.reduce((s, f) => s + f.contentsCount, 0), - 0, - ); + + const totalFeedsContents = userFeedsGroupedByFolder.values().reduce( + (acc, folder) => + acc + + folder.feeds.reduce((sacc, f) => { + const serverUnread = f.contentsCount - f.readContentsCount; + const resolved = feedsReadCount.getUnreadCount(f.id, serverUnread); + return sacc + resolved; + }, 0), + 0, + ); const { ref, isDropTarget } = useDroppable({ id: UNCATEGORIZED_FEEDS_FOLDER_ID, }); diff --git a/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsx b/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsx index 33bd1c3b..77967205 100644 --- a/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsx +++ b/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsx @@ -39,6 +39,7 @@ import { type FeedWithContentsCount, } from "@/lib/constants"; import { useIsMobile } from "@/lib/hooks/use-mobile"; +import { useFeedsUnereadCount } from "@/lib/stores/feeds-read-count-context"; import { searchParamsState } from "@/lib/stores/search-params-states"; type Props = { @@ -114,6 +115,7 @@ function Draggable({ feed }: { feed: FeedWithContentsCount }) { urlKeys: searchParamsState.urlKeys, }, ); + const feedsReadCount = useFeedsUnereadCount(); return ( {feed.title} - {feed.contentsCount} + + {feedsReadCount.getUnreadCount( + feed.id, + feed.contentsCount - feed.readContentsCount, + )} + diff --git a/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsx b/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsx index acd278eb..ae78f816 100644 --- a/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsx +++ b/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsx @@ -12,6 +12,7 @@ import { } from "@/components/ui/sidebar"; import { FeedStatusType, type FeedWithContentsCount } from "@/lib/constants"; import { useIsMobile } from "@/lib/hooks/use-mobile"; +import { useFeedsUnereadCount } from "@/lib/stores/feeds-read-count-context"; import { searchParamsState } from "@/lib/stores/search-params-states"; type Props = { @@ -30,6 +31,7 @@ export function FeedsSidebarItem({ feed }: Props) { id: feed.id, data: feed, }); + const feedsReadCount = useFeedsUnereadCount(); return ( {feed.title} - {feed.contentsCount} + + {feedsReadCount.getUnreadCount( + feed.id, + feed.contentsCount - feed.readContentsCount, + )} + diff --git a/src/lib/constants.ts b/src/lib/constants.ts index fa08d4d6..7e845b01 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -20,7 +20,10 @@ export type FeedWithContentsCount = { url: string; status: FeedStatusType; folderId: FolderId; + /** Total number of items. */ contentsCount: number; + /** Number of items that were read. */ + readContentsCount: number; }; export enum SortOptions { diff --git a/src/lib/dal.ts b/src/lib/dal.ts index b79022b4..74ebe967 100644 --- a/src/lib/dal.ts +++ b/src/lib/dal.ts @@ -12,6 +12,7 @@ import { links, usersFeeds, usersFeedsFolders, + usersFeedsReadContent, } from "@/db/schema"; import "server-only"; import { z } from "zod/v4"; @@ -166,10 +167,18 @@ const getUserFeedsWithContentsCount = cache( status: feeds.status, folderId: sql`COALESCE(${usersFeeds.folderId}, ${UNCATEGORIZED_FEEDS_FOLDER_ID})`, contentsCount: count(feedsContent.id), + readContentsCount: count(usersFeedsReadContent.readAt), }) .from(feeds) .innerJoin(usersFeeds, eq(usersFeeds.feedId, feeds.id)) .leftJoin(feedsContent, eq(feedsContent.feedId, feeds.id)) + .leftJoin( + usersFeedsReadContent, + and( + eq(usersFeedsReadContent.feedContentId, feedsContent.id), + eq(usersFeedsReadContent.userId, user.user.id), + ), + ) .where(eq(usersFeeds.userId, user.user.id)) .groupBy(feeds.id, usersFeeds.folderId); } catch (err) { @@ -183,7 +192,7 @@ const getUserFeedsWithContentsCount = cache( * getUserFeedsGroupedByFolder gets: * 1. All the feeds folders a user has (including empty folders), with * the feeds in each of those folders. - * 3. And the feeds that are not in a folder. + * 2. And the feeds that are not in a folder. * In other words: feeds in folder, empty folders, feeds without a folder */ const getUserFeedsGroupedByFolder = cache(async (): Promise => { @@ -217,10 +226,18 @@ const getUserFeedsGroupedByFolder = cache(async (): Promise => { status: feeds.status, folderId: usersFeeds.folderId, contentsCount: count(feedsContent.id), + readContentsCount: count(usersFeedsReadContent.readAt), }) .from(feeds) .leftJoin(usersFeeds, eq(usersFeeds.feedId, feeds.id)) .leftJoin(feedsContent, eq(feedsContent.feedId, usersFeeds.feedId)) + .leftJoin( + usersFeedsReadContent, + and( + eq(usersFeedsReadContent.feedContentId, feedsContent.id), + eq(usersFeedsReadContent.userId, user.user.id), + ), + ) .where(eq(usersFeeds.userId, user.user.id)) .groupBy(feeds.id, usersFeeds.folderId) .orderBy(feeds.title); @@ -251,20 +268,21 @@ const getUserFeedsGroupedByFolder = cache(async (): Promise => { // Add feeds to map. for (const feed of userFeeds) { if (!feed.folderId) { - const uncategorized = folders.find( + const uncategorizedFolder = folders.find( (folder) => folder.folderId === UNCATEGORIZED_FEEDS_FOLDER_ID, ); - if (!uncategorized) { + if (!uncategorizedFolder) { throw new Error( "Uncategorized folder not found. It should have been created before hand.", ); } - uncategorized.feeds.push({ + uncategorizedFolder.feeds.push({ id: feed.id, title: feed.title, url: feed.url, status: feed.status, contentsCount: feed.contentsCount, + readContentsCount: feed.readContentsCount, folderId: UNCATEGORIZED_FEEDS_FOLDER_ID, }); } else { @@ -282,6 +300,7 @@ const getUserFeedsGroupedByFolder = cache(async (): Promise => { url: feed.url, status: feed.status, contentsCount: feed.contentsCount, + readContentsCount: feed.readContentsCount, folderId: feed.folderId, }); } diff --git a/src/lib/stores/feeds-read-count-context.tsx b/src/lib/stores/feeds-read-count-context.tsx new file mode 100644 index 00000000..3f9aa484 --- /dev/null +++ b/src/lib/stores/feeds-read-count-context.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { createContext, useContext, useState } from "react"; + +type feedId = number; +type FeedsUnreadCountContextType = { + getUnreadCount: (feedId: number, serverUnread: number) => number; + setOptimisticUnread: ( + feedId: number, + optimistic: number, + baseline: number, + ) => void; + clearOptimistic: (feedId: number) => void; +}; +const FeedsReadCountContext = createContext( + null, +); + +export const useFeedsUnereadCount = () => { + const ctx = useContext(FeedsReadCountContext); + if (!ctx) { + throw new Error( + `${useFeedsUnereadCount.name} must be used within a ${FeedsUnreadCountProvider.name}`, + ); + } + return ctx; +}; + +export default function FeedsUnreadCountProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [optimistic, setOptimistic] = useState< + Map + >(new Map()); + + const getUnreadCount = (feedId: feedId, serverUnreadCount: number) => { + const entry = optimistic.get(feedId); + if (!entry) return serverUnreadCount; + // server has caught up -> ignore optimistic automatically. + if (serverUnreadCount !== entry.baseline) return serverUnreadCount; + return entry.optimistic; + }; + + const setOptimisticUnread = ( + feedId: number, + optimistic: number, + baseline: number, + ) => { + setOptimistic((prev) => + new Map(prev).set(feedId, { optimistic, baseline }), + ); + }; + + const clearOptimistic = (feedId: number) => + setOptimistic((prev) => { + const next = new Map(prev); + next.delete(feedId); + return next; + }); + + return ( + + {children} + + ); +}