From fb52b25834b95ff9a11f8c7d3c20453e26d3bd4f Mon Sep 17 00:00:00 2001
From: nsfisis <nsfisis@gmail.com>
Date: Tue, 22 Jul 2025 05:21:40 +0900
Subject: feat(frontend): Optimistically update read state

---
 frontend/src/components/ArticleItem.tsx | 35 ++++++++++++++++++++++++---------
 1 file changed, 26 insertions(+), 9 deletions(-)

(limited to 'frontend/src/components/ArticleItem.tsx')

diff --git a/frontend/src/components/ArticleItem.tsx b/frontend/src/components/ArticleItem.tsx
index e5a506f..8fb00b0 100644
--- a/frontend/src/components/ArticleItem.tsx
+++ b/frontend/src/components/ArticleItem.tsx
@@ -1,5 +1,6 @@
 import { faCheck, faCircle } from "@fortawesome/free-solid-svg-icons";
 import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import { useOptimistic } from "react";
 import { useMutation } from "urql";
 import type {
 	GetReadArticlesQuery,
@@ -24,10 +25,22 @@ export function ArticleItem({ article, showReadStatus = true }: Props) {
 	const [, markArticleRead] = useMutation(MarkArticleReadDocument);
 	const [, markArticleUnread] = useMutation(MarkArticleUnreadDocument);
 
+	const [optimisticArticle, setOptimisticArticle] = useOptimistic(
+		article,
+		(currentArticle, newReadState: boolean) => ({
+			...currentArticle,
+			isRead: newReadState,
+		}),
+	);
+
 	const handleToggleRead = async (
 		articleId: string,
 		isCurrentlyRead: boolean,
 	) => {
+		const newReadState = !isCurrentlyRead;
+
+		setOptimisticArticle(newReadState);
+
 		if (isCurrentlyRead) {
 			await markArticleUnread({ id: articleId });
 		} else {
@@ -38,7 +51,9 @@ export function ArticleItem({ article, showReadStatus = true }: Props) {
 	const handleArticleClick = async (article: Article) => {
 		// Open article in new tab and mark as read if it's unread
 		window.open(article.url, "_blank", "noreferrer");
-		if (!article.isRead) {
+		if (!optimisticArticle.isRead) {
+			setOptimisticArticle(true);
+
 			await markArticleRead({ id: article.id });
 		}
 	};
@@ -46,7 +61,7 @@ export function ArticleItem({ article, showReadStatus = true }: Props) {
 	return (
 		<div
 			className={`group flex items-center gap-3 rounded-lg border p-3 hover:bg-gray-50 ${
-				article.isRead
+				optimisticArticle.isRead
 					? "border-gray-200 bg-white"
 					: "border-blue-200 bg-blue-50"
 			}`}
@@ -54,16 +69,16 @@ export function ArticleItem({ article, showReadStatus = true }: Props) {
 			{showReadStatus && (
 				<button
 					type="button"
-					onClick={() => handleToggleRead(article.id, article.isRead)}
+					onClick={() => handleToggleRead(article.id, optimisticArticle.isRead)}
 					className={`flex-shrink-0 rounded p-1 transition-colors ${
-						article.isRead
+						optimisticArticle.isRead
 							? "text-gray-400 hover:text-gray-600"
 							: "text-blue-600 hover:text-blue-700"
 					}`}
-					title={article.isRead ? "Mark as unread" : "Mark as read"}
+					title={optimisticArticle.isRead ? "Mark as unread" : "Mark as read"}
 				>
 					<FontAwesomeIcon
-						icon={article.isRead ? faCheck : faCircle}
+						icon={optimisticArticle.isRead ? faCheck : faCircle}
 						className="w-4 h-4"
 					/>
 				</button>
@@ -71,13 +86,15 @@ export function ArticleItem({ article, showReadStatus = true }: Props) {
 			<div className="flex-1 min-w-0">
 				<button
 					type="button"
-					onClick={() => handleArticleClick(article)}
+					onClick={() => handleArticleClick(optimisticArticle)}
 					className={`text-left w-full group-hover:text-blue-600 transition-colors ${
-						article.isRead ? "text-gray-700" : "text-gray-900 font-medium"
+						optimisticArticle.isRead
+							? "text-gray-700"
+							: "text-gray-900 font-medium"
 					}`}
 				>
 					<div className="flex items-center gap-2 break-words">
-						{article.title}
+						{optimisticArticle.title}
 					</div>
 				</button>
 			</div>
-- 
cgit v1.2.3-70-g09d2

