From 97180bce455975622f998f3f1ae7c364d5c0a106 Mon Sep 17 00:00:00 2001 From: "dnncrye.dev" <29934021+mtlaso@users.noreply.github.com> Date: Fri, 6 Mar 2026 22:55:00 -0500 Subject: [PATCH 01/13] chore: retirer variable inutile --- src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx | 6 +----- src/lib/dal.ts | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) 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 99547ce..efcedc2 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"; @@ -34,7 +34,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 +80,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; diff --git a/src/lib/dal.ts b/src/lib/dal.ts index b79022b..074158a 100644 --- a/src/lib/dal.ts +++ b/src/lib/dal.ts @@ -183,7 +183,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 => { From 32caa6a7f444f7f15ffc7a60e119f183e5fab04b Mon Sep 17 00:00:00 2001 From: "dnncrye.dev" <29934021+mtlaso@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:11:44 -0500 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20afficher=20le=20nombre=20d'=C3=A9?= =?UTF-8?q?l=C3=A9ments=20non=20lus=20sur=20les=20flux=20dans=20des=20doss?= =?UTF-8?q?iers.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feeds/sidebar/feeds-sidebar-content.tsx | 2 +- .../ui/feeds/sidebar/feeds-sidebar-folder.tsx | 4 +++- src/lib/constants.ts | 3 +++ src/lib/dal.ts | 19 ++++++++++++++++--- 4 files changed, 23 insertions(+), 5 deletions(-) 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 efcedc2..dea06a6 100644 --- a/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx +++ b/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx @@ -155,7 +155,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, 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 33bd1c3..09675e1 100644 --- a/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsx +++ b/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsx @@ -141,7 +141,9 @@ function Draggable({ feed }: { feed: FeedWithContentsCount }) { isDragging={isDragging} /> {feed.title} - {feed.contentsCount} + + {feed.contentsCount - feed.readContentsCount} + diff --git a/src/lib/constants.ts b/src/lib/constants.ts index fa08d4d..f94274c 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 where read. */ + readContentsCount: number; }; export enum SortOptions { diff --git a/src/lib/dal.ts b/src/lib/dal.ts index 074158a..974c517 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,15 @@ 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, + eq(usersFeedsReadContent.feedContentId, feedsContent.id), + ) .where(eq(usersFeeds.userId, user.user.id)) .groupBy(feeds.id, usersFeeds.folderId); } catch (err) { @@ -217,10 +223,15 @@ 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, + eq(usersFeedsReadContent.feedContentId, feedsContent.id), + ) .where(eq(usersFeeds.userId, user.user.id)) .groupBy(feeds.id, usersFeeds.folderId) .orderBy(feeds.title); @@ -251,20 +262,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 +294,7 @@ const getUserFeedsGroupedByFolder = cache(async (): Promise => { url: feed.url, status: feed.status, contentsCount: feed.contentsCount, + readContentsCount: feed.readContentsCount, folderId: feed.folderId, }); } From cbb577c10f2de5d63814b4ef608ad5cd5876fef9 Mon Sep 17 00:00:00 2001 From: "dnncrye.dev" <29934021+mtlaso@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:14:05 -0500 Subject: [PATCH 03/13] =?UTF-8?q?feat:=20afficher=20le=20nombre=20d'=C3=A9?= =?UTF-8?q?l=C3=A9ments=20non=20lus=20sur=20les=20flux=20qui=20ne=20sont?= =?UTF-8?q?=20pas=20dans=20des=20dossiers.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 acd278e..be1d5c8 100644 --- a/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsx +++ b/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsx @@ -57,7 +57,9 @@ export function FeedsSidebarItem({ feed }: Props) { isDragging={isDragging} /> {feed.title} - {feed.contentsCount} + + {feed.contentsCount - feed.readContentsCount} + From d5f68fb44064603fc62fd0495354e4931d172349 Mon Sep 17 00:00:00 2001 From: "dnncrye.dev" <29934021+mtlaso@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:15:40 -0500 Subject: [PATCH 04/13] =?UTF-8?q?feat:=20afficher=20le=20nombre=20d'=C3=A9?= =?UTF-8?q?l=C3=A9ments=20non=20lus=20sur=20les=20flux=20dans=20la=20barre?= =?UTF-8?q?=20lat=C3=A9rale=20(titre).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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 dea06a6..0901b58 100644 --- a/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx +++ b/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx @@ -172,7 +172,11 @@ function Content({ .values() .reduce( (acc, folder) => - acc + folder.feeds.reduce((s, f) => s + f.contentsCount, 0), + acc + + folder.feeds.reduce( + (sacc, f) => sacc + (f.contentsCount - f.readContentsCount), + 0, + ), 0, ); const { ref, isDropTarget } = useDroppable({ From 91ed416b7a7bd68baa8ed6d3a3abee9e33b5d04c Mon Sep 17 00:00:00 2001 From: "dnncrye.dev" <29934021+mtlaso@users.noreply.github.com> Date: Sat, 7 Mar 2026 00:40:57 -0500 Subject: [PATCH 05/13] =?UTF-8?q?fix:=20filiter=20aussi=20par=20utiliser?= =?UTF-8?q?=20dans=20le=20`leftJoin`,=20car=20plusieurs=20utilisateurs=20p?= =?UTF-8?q?ourraient=20suivre=20le=20m=C3=AAmes=20flux,=20donc=20avoir=20l?= =?UTF-8?q?es=20m=C3=AAmes=20readContentsCount.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The where(eq(usersFeeds.userId, user.user.id)) only filters which feeds the current user is subscribed to — it scopes rows from the usersFeeds table. It has no effect on usersFeedsReadContent. As confirmed by the schema, usersFeedsReadContent has a composite primary key of (userId, feedId, feedContentId), meaning multiple users can have rows for the exact same feedContentId. The leftJoin is performed before the WHERE filter, and it matches solely on feedContentId = feedsContent.id — so all users' read rows for that content are matched. Concrete example: User A and User B both subscribe to Feed X (10 articles). User B has read 7 articles → 7 rows in usersFeedsReadContent with userId = B. When User A runs this query, the leftJoin matches those 7 rows (because feedContentId matches), and count(readAt) returns 7 for User A — even though User A has read 0 articles. The fix needs a userId filter on the join itself. Since and is already imported, it's a small change — and it applies to both getUserFeedsWithContentsCount and getUserFeedsGroupedByFolder: --- src/lib/constants.ts | 2 +- src/lib/dal.ts | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/lib/constants.ts b/src/lib/constants.ts index f94274c..7e845b0 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -22,7 +22,7 @@ export type FeedWithContentsCount = { folderId: FolderId; /** Total number of items. */ contentsCount: number; - /** Number of items that where read. */ + /** Number of items that were read. */ readContentsCount: number; }; diff --git a/src/lib/dal.ts b/src/lib/dal.ts index 974c517..74ebe96 100644 --- a/src/lib/dal.ts +++ b/src/lib/dal.ts @@ -174,7 +174,10 @@ const getUserFeedsWithContentsCount = cache( .leftJoin(feedsContent, eq(feedsContent.feedId, feeds.id)) .leftJoin( usersFeedsReadContent, - eq(usersFeedsReadContent.feedContentId, feedsContent.id), + and( + eq(usersFeedsReadContent.feedContentId, feedsContent.id), + eq(usersFeedsReadContent.userId, user.user.id), + ), ) .where(eq(usersFeeds.userId, user.user.id)) .groupBy(feeds.id, usersFeeds.folderId); @@ -230,7 +233,10 @@ const getUserFeedsGroupedByFolder = cache(async (): Promise => { .leftJoin(feedsContent, eq(feedsContent.feedId, usersFeeds.feedId)) .leftJoin( usersFeedsReadContent, - eq(usersFeedsReadContent.feedContentId, feedsContent.id), + and( + eq(usersFeedsReadContent.feedContentId, feedsContent.id), + eq(usersFeedsReadContent.userId, user.user.id), + ), ) .where(eq(usersFeeds.userId, user.user.id)) .groupBy(feeds.id, usersFeeds.folderId) From 13859fd1e24d753a4a2469eb757578371636bb7b Mon Sep 17 00:00:00 2001 From: "dnncrye.dev" <29934021+mtlaso@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:08:37 -0400 Subject: [PATCH 06/13] =?UTF-8?q?feat:=20optimistic=20updates=20du=20compt?= =?UTF-8?q?e=20du=20nombre=20d'=C3=A9l=C3=A9ments=20restants=20=C3=A0=20?= =?UTF-8?q?=C3=AAtre=20lus.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(app)/d/(sidebar)/feeds/layout.tsx | 13 ++-- src/app/[locale]/ui/feeds/feeds-timeline.tsx | 10 +++ .../feeds/sidebar/feeds-sidebar-content.tsx | 8 ++- .../ui/feeds/sidebar/feeds-sidebar-folder.tsx | 6 +- .../ui/feeds/sidebar/feeds-sidebar-item.tsx | 6 +- src/lib/stores/feeds-read-count-context.tsx | 70 +++++++++++++++++++ 6 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 src/lib/stores/feeds-read-count-context.tsx diff --git a/src/app/[locale]/(app)/d/(sidebar)/feeds/layout.tsx b/src/app/[locale]/(app)/d/(sidebar)/feeds/layout.tsx index ad0c9bf..fe909dd 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 FeedsReadCountProvider 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 e3a1567..25f51c6 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 { useFeedsReadCount } 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"; @@ -55,6 +56,7 @@ const Item = ({ item }: { item: FeedTimeline }): React.JSX.Element => { const locale = useLocale(); const [isRead, setIsRead] = useOptimistic(item.readAt !== null); + const feedsReadCount = useFeedsReadCount(); const handleMarkAsRead = async ( feedId: number, @@ -62,22 +64,26 @@ const Item = ({ item }: { item: FeedTimeline }): React.JSX.Element => { ): Promise => { startTransition(async () => { try { + feedsReadCount.updateReadCount(feedId, -1); setIsRead(true); const res = await markFeedContentAsRead(feedId, feedContentId); if (res.errors) { + feedsReadCount.updateReadCount(feedId, +1); setIsRead(false); toast.error([res.errors.feedId, res.errors.feedContentId].join(", ")); return; } if (res.errI18Key) { + feedsReadCount.updateReadCount(feedId, +1); setIsRead(false); // biome-ignore lint/suspicious/noExplicitAny: valid type. toast.error(t(res.errI18Key as any)); return; } } catch (err) { + feedsReadCount.updateReadCount(feedId, +1); setIsRead(false); if (err instanceof Error) { toast.error(err.message); @@ -94,22 +100,26 @@ const Item = ({ item }: { item: FeedTimeline }): React.JSX.Element => { ): Promise => { startTransition(async () => { try { + feedsReadCount.updateReadCount(feedId, +1); setIsRead(false); const res = await markFeedContentAsUnread(feedId, feedContentId); if (res.errors) { + feedsReadCount.updateReadCount(feedId, -1); setIsRead(true); toast.error([res.errors.feedId, res.errors.feedContentId].join(", ")); return; } if (res.errI18Key) { + feedsReadCount.updateReadCount(feedId, -1); setIsRead(true); // biome-ignore lint/suspicious/noExplicitAny: valid type. toast.error(t(res.errI18Key as any)); return; } } catch (err) { + feedsReadCount.updateReadCount(feedId, +1); 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 0901b58..6666b93 100644 --- a/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx +++ b/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx @@ -21,6 +21,7 @@ import { type FeedWithContentsCount, UNCATEGORIZED_FEEDS_FOLDER_ID, } from "@/lib/constants"; +import { useFeedsReadCount } from "@/lib/stores/feeds-read-count-context"; type Props = { userFeedsGroupedByFolderPromise: Promise; @@ -164,6 +165,7 @@ function Content({ handleOnRemove: (id: number) => void; }) { const t = useTranslations("rssFeed"); + const feedsReadCount = useFeedsReadCount(); const totalFeeds = userFeedsGroupedByFolder.values().reduce((acc, folder) => { return acc + folder.feeds.length; @@ -174,7 +176,11 @@ function Content({ (acc, folder) => acc + folder.feeds.reduce( - (sacc, f) => sacc + (f.contentsCount - f.readContentsCount), + (sacc, f) => + sacc + + (f.contentsCount - + f.readContentsCount + + feedsReadCount.getReadCount(f.id)), 0, ), 0, 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 09675e1..e36c4c7 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 { useFeedsReadCount } 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 = useFeedsReadCount(); return ( {feed.title} - {feed.contentsCount - feed.readContentsCount} + {feed.contentsCount - + feed.readContentsCount + + feedsReadCount.getReadCount(feed.id)} 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 be1d5c8..5a5ca1b 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 { useFeedsReadCount } 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 = useFeedsReadCount(); return ( {feed.title} - {feed.contentsCount - feed.readContentsCount} + {feed.contentsCount - + feed.readContentsCount + + feedsReadCount.getReadCount(feed.id)} 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 0000000..182b927 --- /dev/null +++ b/src/lib/stores/feeds-read-count-context.tsx @@ -0,0 +1,70 @@ +"use client"; +import { createContext, useContext, useState } from "react"; + +type feedId = number; +/** + * delta represents the change amount. + * e.g : +1, -1. + */ +type delta = number; +type FeedsReadCountContextType = { + /** + * Returns the read count for a given feedId. + */ + getReadCount: (feedId: feedId) => delta; + /** + * + * @param feedId feedId + * @param delta represents the change amount. + * e.g : +1, -1. + * @returns + */ + updateReadCount: (feedId: feedId, delta: delta) => void; + /** + * Resets the read count for a given feedId to 0. + * + * @param feedId feedId + */ + resetReadCount: (feedId: feedId) => void; +}; +const FeedsReadCountContext = createContext( + null, +); + +export const useFeedsReadCount = () => { + const ctx = useContext(FeedsReadCountContext); + if (!ctx) { + throw new Error( + "useFeedsReadCount must be used within a FeedsReadCountProvider", + ); + } + return ctx; +}; + +export default function FeedsReadCountProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [readCount, setReadCount] = useState>(new Map()); + + const getReadCount = (feedId: feedId) => { + return readCount.get(feedId) ?? 0; + }; + + const updateReadCount = (feedId: feedId, delta: number) => { + setReadCount((prev) => new Map(prev).set(feedId, delta)); + }; + + const resetReadCount = (feedId: feedId) => { + setReadCount((prev) => new Map(prev).set(feedId, 0)); + }; + + return ( + + {children} + + ); +} From b477ffe84aae871c0703b9268df3d58cccfbf20d Mon Sep 17 00:00:00 2001 From: "dnncrye.dev" <29934021+mtlaso@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:16:59 -0400 Subject: [PATCH 07/13] fix: inverser updateReadCount --- src/app/[locale]/ui/feeds/feeds-timeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/[locale]/ui/feeds/feeds-timeline.tsx b/src/app/[locale]/ui/feeds/feeds-timeline.tsx index 25f51c6..17d81b3 100644 --- a/src/app/[locale]/ui/feeds/feeds-timeline.tsx +++ b/src/app/[locale]/ui/feeds/feeds-timeline.tsx @@ -119,7 +119,7 @@ const Item = ({ item }: { item: FeedTimeline }): React.JSX.Element => { return; } } catch (err) { - feedsReadCount.updateReadCount(feedId, +1); + feedsReadCount.updateReadCount(feedId, -1); setIsRead(true); if (err instanceof Error) { toast.error(err.message); From 2c65bd8a0d38a2f48ec6f6cb9dc45c72e526c04d Mon Sep 17 00:00:00 2001 From: "dnncrye.dev" <29934021+mtlaso@users.noreply.github.com> Date: Thu, 12 Mar 2026 23:19:55 -0400 Subject: [PATCH 08/13] feat: reset les delatas une fois l'action terminee --- src/app/[locale]/ui/feeds/feeds-timeline.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/[locale]/ui/feeds/feeds-timeline.tsx b/src/app/[locale]/ui/feeds/feeds-timeline.tsx index 17d81b3..ae7fe53 100644 --- a/src/app/[locale]/ui/feeds/feeds-timeline.tsx +++ b/src/app/[locale]/ui/feeds/feeds-timeline.tsx @@ -82,6 +82,8 @@ const Item = ({ item }: { item: FeedTimeline }): React.JSX.Element => { toast.error(t(res.errI18Key as any)); return; } + + feedsReadCount.resetReadCount(feedId); } catch (err) { feedsReadCount.updateReadCount(feedId, +1); setIsRead(false); @@ -118,6 +120,8 @@ const Item = ({ item }: { item: FeedTimeline }): React.JSX.Element => { toast.error(t(res.errI18Key as any)); return; } + + feedsReadCount.resetReadCount(feedId); } catch (err) { feedsReadCount.updateReadCount(feedId, -1); setIsRead(true); From 523b09d99d454f3e3e9109977c589d043bf0ad84 Mon Sep 17 00:00:00 2001 From: "dnncrye.dev" <29934021+mtlaso@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:03:43 -0400 Subject: [PATCH 09/13] fix: fixxxxxx bichhh --- src/app/[locale]/ui/feeds/feeds-timeline.tsx | 21 +++++----- .../feeds/sidebar/feeds-sidebar-content.tsx | 28 +++++-------- .../ui/feeds/sidebar/feeds-sidebar-folder.tsx | 2 +- .../ui/feeds/sidebar/feeds-sidebar-item.tsx | 2 +- src/lib/stores/feeds-read-count-context.tsx | 40 ++++++++++++------- 5 files changed, 50 insertions(+), 43 deletions(-) diff --git a/src/app/[locale]/ui/feeds/feeds-timeline.tsx b/src/app/[locale]/ui/feeds/feeds-timeline.tsx index ae7fe53..599b769 100644 --- a/src/app/[locale]/ui/feeds/feeds-timeline.tsx +++ b/src/app/[locale]/ui/feeds/feeds-timeline.tsx @@ -64,28 +64,29 @@ const Item = ({ item }: { item: FeedTimeline }): React.JSX.Element => { ): Promise => { startTransition(async () => { try { - feedsReadCount.updateReadCount(feedId, -1); + feedsReadCount.updateDelta(feedId, -1); setIsRead(true); const res = await markFeedContentAsRead(feedId, feedContentId); if (res.errors) { - feedsReadCount.updateReadCount(feedId, +1); + feedsReadCount.updateDelta(feedId, +1); setIsRead(false); toast.error([res.errors.feedId, res.errors.feedContentId].join(", ")); return; } if (res.errI18Key) { - feedsReadCount.updateReadCount(feedId, +1); + feedsReadCount.updateDelta(feedId, +1); setIsRead(false); // biome-ignore lint/suspicious/noExplicitAny: valid type. toast.error(t(res.errI18Key as any)); return; } - feedsReadCount.resetReadCount(feedId); + // Ensure no double substract. + feedsReadCount.resetDelta(feedId); } catch (err) { - feedsReadCount.updateReadCount(feedId, +1); + feedsReadCount.updateDelta(feedId, +1); setIsRead(false); if (err instanceof Error) { toast.error(err.message); @@ -102,28 +103,28 @@ const Item = ({ item }: { item: FeedTimeline }): React.JSX.Element => { ): Promise => { startTransition(async () => { try { - feedsReadCount.updateReadCount(feedId, +1); + feedsReadCount.updateDelta(feedId, +1); setIsRead(false); const res = await markFeedContentAsUnread(feedId, feedContentId); if (res.errors) { - feedsReadCount.updateReadCount(feedId, -1); + feedsReadCount.updateDelta(feedId, -1); setIsRead(true); toast.error([res.errors.feedId, res.errors.feedContentId].join(", ")); return; } if (res.errI18Key) { - feedsReadCount.updateReadCount(feedId, -1); + feedsReadCount.updateDelta(feedId, -1); setIsRead(true); // biome-ignore lint/suspicious/noExplicitAny: valid type. toast.error(t(res.errI18Key as any)); return; } - feedsReadCount.resetReadCount(feedId); + feedsReadCount.resetDelta(feedId); } catch (err) { - feedsReadCount.updateReadCount(feedId, -1); + feedsReadCount.updateDelta(feedId, -1); 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 6666b93..f6c9074 100644 --- a/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx +++ b/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx @@ -21,7 +21,6 @@ import { type FeedWithContentsCount, UNCATEGORIZED_FEEDS_FOLDER_ID, } from "@/lib/constants"; -import { useFeedsReadCount } from "@/lib/stores/feeds-read-count-context"; type Props = { userFeedsGroupedByFolderPromise: Promise; @@ -165,26 +164,21 @@ function Content({ handleOnRemove: (id: number) => void; }) { const t = useTranslations("rssFeed"); - const feedsReadCount = useFeedsReadCount(); + // const feedsReadCount = useFeedsReadCount(); const totalFeeds = userFeedsGroupedByFolder.values().reduce((acc, folder) => { return acc + folder.feeds.length; }, 0); - const totalFeedsContents = userFeedsGroupedByFolder - .values() - .reduce( - (acc, folder) => - acc + - folder.feeds.reduce( - (sacc, f) => - sacc + - (f.contentsCount - - f.readContentsCount + - feedsReadCount.getReadCount(f.id)), - 0, - ), - 0, - ); + const totalFeedsContents = userFeedsGroupedByFolder.values().reduce( + (acc, folder) => + acc + + folder.feeds.reduce( + (sacc, f) => sacc + (f.contentsCount - f.readContentsCount), + // + feedsReadCount.getReadCount(f.id) + 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 e36c4c7..c145e86 100644 --- a/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsx +++ b/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsx @@ -146,7 +146,7 @@ function Draggable({ feed }: { feed: FeedWithContentsCount }) { {feed.contentsCount - feed.readContentsCount + - feedsReadCount.getReadCount(feed.id)} + feedsReadCount.getDelta(feed.id)} 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 5a5ca1b..e1aabb6 100644 --- a/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsx +++ b/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsx @@ -62,7 +62,7 @@ export function FeedsSidebarItem({ feed }: Props) { {feed.contentsCount - feed.readContentsCount + - feedsReadCount.getReadCount(feed.id)} + feedsReadCount.getDelta(feed.id)} diff --git a/src/lib/stores/feeds-read-count-context.tsx b/src/lib/stores/feeds-read-count-context.tsx index 182b927..61e4abc 100644 --- a/src/lib/stores/feeds-read-count-context.tsx +++ b/src/lib/stores/feeds-read-count-context.tsx @@ -9,23 +9,22 @@ type feedId = number; type delta = number; type FeedsReadCountContextType = { /** - * Returns the read count for a given feedId. + * Returns the delta change for a given feedId. */ - getReadCount: (feedId: feedId) => delta; + getDelta: (feedId: feedId) => delta; /** * * @param feedId feedId * @param delta represents the change amount. * e.g : +1, -1. - * @returns */ - updateReadCount: (feedId: feedId, delta: delta) => void; + updateDelta: (feedId: feedId, delta: delta) => void; /** - * Resets the read count for a given feedId to 0. + * Resets the delata for a given feedId to 0. * * @param feedId feedId */ - resetReadCount: (feedId: feedId) => void; + resetDelta: (feedId: feedId) => void; }; const FeedsReadCountContext = createContext( null, @@ -46,23 +45,36 @@ export default function FeedsReadCountProvider({ }: { children: React.ReactNode; }) { - const [readCount, setReadCount] = useState>(new Map()); + const [delta, setDelata] = useState>(new Map()); - const getReadCount = (feedId: feedId) => { - return readCount.get(feedId) ?? 0; + const getDelta = (feedId: feedId) => { + return delta.get(feedId) ?? 0; }; - const updateReadCount = (feedId: feedId, delta: number) => { - setReadCount((prev) => new Map(prev).set(feedId, delta)); + const updateDelta = (feedId: feedId, delta: number) => { + setDelata((prev) => { + const current = prev.get(feedId) ?? 0; + const next = new Map(prev); + next.set(feedId, current + delta); + return next; + }); }; - const resetReadCount = (feedId: feedId) => { - setReadCount((prev) => new Map(prev).set(feedId, 0)); + // un/markFeedContentAsRead calls revalidatePath server-side. + // By the time the client's startTransition callback finishes, Next.js has already pushed the updated RSC payload, + // meaning userFeedsGroupedByFolderPromise resolves with fresh delta. + // Calling resetDeltas() at that point ensures the delta doesn't double-subtract from the already-updated server value. + const resetDelta = (feedId: feedId) => { + setDelata((prev) => new Map(prev).set(feedId, 0)); }; return ( {children} From 5b54c9420865d2843d8eef85186051c1df9a308c Mon Sep 17 00:00:00 2001 From: "dnncrye.dev" <29934021+mtlaso@users.noreply.github.com> Date: Fri, 13 Mar 2026 00:39:34 -0400 Subject: [PATCH 10/13] utiliser contexte pour afficher le nb total d'element --- .../feeds/sidebar/feeds-sidebar-content.tsx | 28 +++++++++++-------- 1 file changed, 17 insertions(+), 11 deletions(-) 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 f6c9074..f488a1d 100644 --- a/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx +++ b/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx @@ -21,6 +21,7 @@ import { type FeedWithContentsCount, UNCATEGORIZED_FEEDS_FOLDER_ID, } from "@/lib/constants"; +import { useFeedsReadCount } from "@/lib/stores/feeds-read-count-context"; type Props = { userFeedsGroupedByFolderPromise: Promise; @@ -164,21 +165,26 @@ function Content({ handleOnRemove: (id: number) => void; }) { const t = useTranslations("rssFeed"); - // const feedsReadCount = useFeedsReadCount(); + const feedsReadCount = useFeedsReadCount(); const totalFeeds = userFeedsGroupedByFolder.values().reduce((acc, folder) => { return acc + folder.feeds.length; }, 0); - const totalFeedsContents = userFeedsGroupedByFolder.values().reduce( - (acc, folder) => - acc + - folder.feeds.reduce( - (sacc, f) => sacc + (f.contentsCount - f.readContentsCount), - // + feedsReadCount.getReadCount(f.id) - 0, - ), - 0, - ); + const totalFeedsContents = userFeedsGroupedByFolder + .values() + .reduce( + (acc, folder) => + acc + + folder.feeds.reduce( + (sacc, f) => + sacc + + (f.contentsCount - + f.readContentsCount + + feedsReadCount.getDelta(f.id)), + 0, + ), + 0, + ); const { ref, isDropTarget } = useDroppable({ id: UNCATEGORIZED_FEEDS_FOLDER_ID, }); From 08064d001c093a82f8e9f58d02df2d8923ad7b98 Mon Sep 17 00:00:00 2001 From: "dnncrye.dev" <29934021+mtlaso@users.noreply.github.com> Date: Fri, 13 Mar 2026 02:57:10 -0400 Subject: [PATCH 11/13] fix: flash --- .zed/debug.json | 5 + next.config.ts | 2 - .../(app)/d/(sidebar)/feeds/layout.tsx | 6 +- src/app/[locale]/ui/feeds/feeds-timeline.tsx | 98 +++++++++++++++---- .../feeds/sidebar/feeds-sidebar-content.tsx | 34 +++---- .../ui/feeds/sidebar/feeds-sidebar-folder.tsx | 11 ++- .../ui/feeds/sidebar/feeds-sidebar-item.tsx | 11 ++- src/lib/stores/feeds-read-count-context.tsx | 82 +++++++--------- 8 files changed, 155 insertions(+), 94 deletions(-) create mode 100644 .zed/debug.json diff --git a/.zed/debug.json b/.zed/debug.json new file mode 100644 index 0000000..4be1a90 --- /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 447188e..2486940 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 fe909dd..5690edd 100644 --- a/src/app/[locale]/(app)/d/(sidebar)/feeds/layout.tsx +++ b/src/app/[locale]/(app)/d/(sidebar)/feeds/layout.tsx @@ -1,7 +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 FeedsReadCountProvider from "@/lib/stores/feeds-read-count-context"; +import FeedsUnreadCountProvider from "@/lib/stores/feeds-read-count-context"; export default function DashboardLayout({ children, @@ -10,13 +10,13 @@ export default function DashboardLayout({ }): React.JSX.Element { return ( - +
{children}
-
+
); } diff --git a/src/app/[locale]/ui/feeds/feeds-timeline.tsx b/src/app/[locale]/ui/feeds/feeds-timeline.tsx index 599b769..7d9afc1 100644 --- a/src/app/[locale]/ui/feeds/feeds-timeline.tsx +++ b/src/app/[locale]/ui/feeds/feeds-timeline.tsx @@ -12,7 +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 { useFeedsReadCount } from "@/lib/stores/feeds-read-count-context"; +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"; @@ -30,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; @@ -45,48 +55,86 @@ 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 = useFeedsReadCount(); + const feedsReadCount = useFeedsUnereadCount(); + + const wait = async (ms: number) => { + await new Promise((resolve) => setTimeout(resolve, ms)); + }; const handleMarkAsRead = async ( feedId: number, feedContentId: number, ): Promise => { + feedsReadCount.setOptimisticUnread( + feedId, + feedUnreadCounts - 1, + feedUnreadCounts, + ); + startTransition(async () => { try { - feedsReadCount.updateDelta(feedId, -1); setIsRead(true); + await wait(2000); const res = await markFeedContentAsRead(feedId, feedContentId); + // Prevent double subtraction. + feedsReadCount.clearOptimistic(feedId); + // await wait(2000); if (res.errors) { - feedsReadCount.updateDelta(feedId, +1); + feedsReadCount.setOptimisticUnread( + feedId, + feedUnreadCounts + 1, + feedUnreadCounts, + ); setIsRead(false); toast.error([res.errors.feedId, res.errors.feedContentId].join(", ")); return; } if (res.errI18Key) { - feedsReadCount.updateDelta(feedId, +1); + feedsReadCount.setOptimisticUnread( + feedId, + feedUnreadCounts + 1, + feedUnreadCounts, + ); setIsRead(false); // biome-ignore lint/suspicious/noExplicitAny: valid type. toast.error(t(res.errI18Key as any)); return; } - - // Ensure no double substract. - feedsReadCount.resetDelta(feedId); } catch (err) { - feedsReadCount.updateDelta(feedId, +1); + feedsReadCount.setOptimisticUnread( + feedId, + feedUnreadCounts + 1, + feedUnreadCounts, + ); setIsRead(false); if (err instanceof Error) { toast.error(err.message); @@ -101,30 +149,46 @@ const Item = ({ item }: { item: FeedTimeline }): React.JSX.Element => { feedId: number, feedContentId: number, ): Promise => { + feedsReadCount.setOptimisticUnread( + feedId, + feedUnreadCounts + 1, + feedUnreadCounts, + ); startTransition(async () => { try { - feedsReadCount.updateDelta(feedId, +1); setIsRead(false); const res = await markFeedContentAsUnread(feedId, feedContentId); + // Prevent double subtraction. + feedsReadCount.clearOptimistic(feedId); if (res.errors) { - feedsReadCount.updateDelta(feedId, -1); + feedsReadCount.setOptimisticUnread( + feedId, + feedUnreadCounts - 1, + feedUnreadCounts, + ); setIsRead(true); toast.error([res.errors.feedId, res.errors.feedContentId].join(", ")); return; } if (res.errI18Key) { - feedsReadCount.updateDelta(feedId, -1); + feedsReadCount.setOptimisticUnread( + feedId, + feedUnreadCounts - 1, + feedUnreadCounts, + ); setIsRead(true); // biome-ignore lint/suspicious/noExplicitAny: valid type. toast.error(t(res.errI18Key as any)); return; } - - feedsReadCount.resetDelta(feedId); } catch (err) { - feedsReadCount.updateDelta(feedId, -1); + 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 f488a1d..c639a21 100644 --- a/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx +++ b/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx @@ -21,7 +21,7 @@ import { type FeedWithContentsCount, UNCATEGORIZED_FEEDS_FOLDER_ID, } from "@/lib/constants"; -import { useFeedsReadCount } from "@/lib/stores/feeds-read-count-context"; +import { useFeedsUnereadCount } from "@/lib/stores/feeds-read-count-context"; type Props = { userFeedsGroupedByFolderPromise: Promise; @@ -165,26 +165,26 @@ function Content({ handleOnRemove: (id: number) => void; }) { const t = useTranslations("rssFeed"); - const feedsReadCount = useFeedsReadCount(); + 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( - (sacc, f) => - sacc + - (f.contentsCount - - f.readContentsCount + - feedsReadCount.getDelta(f.id)), - 0, - ), - 0, - ); + const totalFeedsContents = userFeedsGroupedByFolder.values().reduce( + (acc, folder) => + acc + + folder.feeds.reduce( + (sacc, f) => + sacc + + feedsReadCount.getUnreadCount( + f.id, + f.contentsCount - f.readContentsCount, + ), + // + feedsReadCount.getDelta(f.id) + 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 c145e86..7796720 100644 --- a/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsx +++ b/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-folder.tsx @@ -39,7 +39,7 @@ import { type FeedWithContentsCount, } from "@/lib/constants"; import { useIsMobile } from "@/lib/hooks/use-mobile"; -import { useFeedsReadCount } from "@/lib/stores/feeds-read-count-context"; +import { useFeedsUnereadCount } from "@/lib/stores/feeds-read-count-context"; import { searchParamsState } from "@/lib/stores/search-params-states"; type Props = { @@ -115,7 +115,7 @@ function Draggable({ feed }: { feed: FeedWithContentsCount }) { urlKeys: searchParamsState.urlKeys, }, ); - const feedsReadCount = useFeedsReadCount(); + const feedsReadCount = useFeedsUnereadCount(); return ( {feed.title} - {feed.contentsCount - - feed.readContentsCount + - feedsReadCount.getDelta(feed.id)} + {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 e1aabb6..ae78f81 100644 --- a/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsx +++ b/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-item.tsx @@ -12,7 +12,7 @@ import { } from "@/components/ui/sidebar"; import { FeedStatusType, type FeedWithContentsCount } from "@/lib/constants"; import { useIsMobile } from "@/lib/hooks/use-mobile"; -import { useFeedsReadCount } from "@/lib/stores/feeds-read-count-context"; +import { useFeedsUnereadCount } from "@/lib/stores/feeds-read-count-context"; import { searchParamsState } from "@/lib/stores/search-params-states"; type Props = { @@ -31,7 +31,7 @@ export function FeedsSidebarItem({ feed }: Props) { id: feed.id, data: feed, }); - const feedsReadCount = useFeedsReadCount(); + const feedsReadCount = useFeedsUnereadCount(); return ( {feed.title} - {feed.contentsCount - - feed.readContentsCount + - feedsReadCount.getDelta(feed.id)} + {feedsReadCount.getUnreadCount( + feed.id, + feed.contentsCount - feed.readContentsCount, + )} diff --git a/src/lib/stores/feeds-read-count-context.tsx b/src/lib/stores/feeds-read-count-context.tsx index 61e4abc..4616fa8 100644 --- a/src/lib/stores/feeds-read-count-context.tsx +++ b/src/lib/stores/feeds-read-count-context.tsx @@ -1,36 +1,22 @@ "use client"; + import { createContext, useContext, useState } from "react"; type feedId = number; -/** - * delta represents the change amount. - * e.g : +1, -1. - */ -type delta = number; -type FeedsReadCountContextType = { - /** - * Returns the delta change for a given feedId. - */ - getDelta: (feedId: feedId) => delta; - /** - * - * @param feedId feedId - * @param delta represents the change amount. - * e.g : +1, -1. - */ - updateDelta: (feedId: feedId, delta: delta) => void; - /** - * Resets the delata for a given feedId to 0. - * - * @param feedId feedId - */ - resetDelta: (feedId: feedId) => void; +type FeedsUnreadCountContextType = { + getUnreadCount: (feedId: number, serverUnread: number) => number; + setOptimisticUnread: ( + feedId: number, + optimistic: number, + baseline: number, + ) => void; + clearOptimistic: (feedId: number) => void; }; -const FeedsReadCountContext = createContext( +const FeedsReadCountContext = createContext( null, ); -export const useFeedsReadCount = () => { +export const useFeedsUnereadCount = () => { const ctx = useContext(FeedsReadCountContext); if (!ctx) { throw new Error( @@ -40,40 +26,46 @@ export const useFeedsReadCount = () => { return ctx; }; -export default function FeedsReadCountProvider({ +export default function FeedsUnreadCountProvider({ children, }: { children: React.ReactNode; }) { - const [delta, setDelata] = useState>(new Map()); + 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 getDelta = (feedId: feedId) => { - return delta.get(feedId) ?? 0; + const setOptimisticUnread = ( + feedId: number, + optimistic: number, + baseline: number, + ) => { + setOptimistic((prev) => + new Map(prev).set(feedId, { optimistic, baseline }), + ); }; - const updateDelta = (feedId: feedId, delta: number) => { - setDelata((prev) => { - const current = prev.get(feedId) ?? 0; + const clearOptimistic = (feedId: number) => + setOptimistic((prev) => { const next = new Map(prev); - next.set(feedId, current + delta); + next.delete(feedId); return next; }); - }; - - // un/markFeedContentAsRead calls revalidatePath server-side. - // By the time the client's startTransition callback finishes, Next.js has already pushed the updated RSC payload, - // meaning userFeedsGroupedByFolderPromise resolves with fresh delta. - // Calling resetDeltas() at that point ensures the delta doesn't double-subtract from the already-updated server value. - const resetDelta = (feedId: feedId) => { - setDelata((prev) => new Map(prev).set(feedId, 0)); - }; return ( {children} From 430a7cd58ab1757215dd14f283acf67c660ffe10 Mon Sep 17 00:00:00 2001 From: "dnncrye.dev" <29934021+mtlaso@users.noreply.github.com> Date: Fri, 13 Mar 2026 03:07:13 -0400 Subject: [PATCH 12/13] fix: retirer clear --- src/app/[locale]/ui/feeds/feeds-timeline.tsx | 9 --------- .../ui/feeds/sidebar/feeds-sidebar-content.tsx | 16 ++++++---------- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/src/app/[locale]/ui/feeds/feeds-timeline.tsx b/src/app/[locale]/ui/feeds/feeds-timeline.tsx index 7d9afc1..9b8ab84 100644 --- a/src/app/[locale]/ui/feeds/feeds-timeline.tsx +++ b/src/app/[locale]/ui/feeds/feeds-timeline.tsx @@ -84,10 +84,6 @@ const Item = ({ const [isRead, setIsRead] = useOptimistic(item.readAt !== null); const feedsReadCount = useFeedsUnereadCount(); - const wait = async (ms: number) => { - await new Promise((resolve) => setTimeout(resolve, ms)); - }; - const handleMarkAsRead = async ( feedId: number, feedContentId: number, @@ -101,11 +97,7 @@ const Item = ({ startTransition(async () => { try { setIsRead(true); - await wait(2000); const res = await markFeedContentAsRead(feedId, feedContentId); - // Prevent double subtraction. - feedsReadCount.clearOptimistic(feedId); - // await wait(2000); if (res.errors) { feedsReadCount.setOptimisticUnread( @@ -159,7 +151,6 @@ const Item = ({ setIsRead(false); const res = await markFeedContentAsUnread(feedId, feedContentId); // Prevent double subtraction. - feedsReadCount.clearOptimistic(feedId); if (res.errors) { feedsReadCount.setOptimisticUnread( 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 c639a21..e864cb4 100644 --- a/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx +++ b/src/app/[locale]/ui/feeds/sidebar/feeds-sidebar-content.tsx @@ -170,19 +170,15 @@ function Content({ const totalFeeds = userFeedsGroupedByFolder.values().reduce((acc, folder) => { return acc + folder.feeds.length; }, 0); + const totalFeedsContents = userFeedsGroupedByFolder.values().reduce( (acc, folder) => acc + - folder.feeds.reduce( - (sacc, f) => - sacc + - feedsReadCount.getUnreadCount( - f.id, - f.contentsCount - f.readContentsCount, - ), - // + feedsReadCount.getDelta(f.id) - 0, - ), + 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({ From 80b861e6f07d10723dcd47c18baf0b2ceec6e977 Mon Sep 17 00:00:00 2001 From: "dnncrye.dev" <29934021+mtlaso@users.noreply.github.com> Date: Fri, 13 Mar 2026 03:10:57 -0400 Subject: [PATCH 13/13] fix: afficher noms correctement --- src/lib/stores/feeds-read-count-context.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/stores/feeds-read-count-context.tsx b/src/lib/stores/feeds-read-count-context.tsx index 4616fa8..3f9aa48 100644 --- a/src/lib/stores/feeds-read-count-context.tsx +++ b/src/lib/stores/feeds-read-count-context.tsx @@ -20,7 +20,7 @@ export const useFeedsUnereadCount = () => { const ctx = useContext(FeedsReadCountContext); if (!ctx) { throw new Error( - "useFeedsReadCount must be used within a FeedsReadCountProvider", + `${useFeedsUnereadCount.name} must be used within a ${FeedsUnreadCountProvider.name}`, ); } return ctx;