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 (
-
-
+
+
+
+
);
}
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}
+
+ );
+}