From b2d8313d3ebf2b687b53fe06d328aaedffe9ea87 Mon Sep 17 00:00:00 2001 From: Paulo Carinhena <63272749+paulocarinhena@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:30:46 -0300 Subject: [PATCH] =?UTF-8?q?Mant=C3=A9m=20o=20estado=20de=20lido=20igual=20?= =?UTF-8?q?na=20lista=20e=20no=20leitor.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Artigo sem registro conta como não lido, abrir uma cópia atualiza as outras na tela, e uma marcação que falha não perde a linha da lista. Co-authored-by: Cursor --- src/app/(app)/article/[id]/page.tsx | 7 ++- .../(app)/article/[id]/standalone-reader.tsx | 2 +- src/app/actions/articles.ts | 34 +++-------- src/components/articles/article-workspace.tsx | 60 +++++++++++++------ src/components/articles/reader.tsx | 2 +- src/lib/article-state.ts | 25 ++++++-- 6 files changed, 75 insertions(+), 55 deletions(-) diff --git a/src/app/(app)/article/[id]/page.tsx b/src/app/(app)/article/[id]/page.tsx index 5f72f86..83b7eaa 100644 --- a/src/app/(app)/article/[id]/page.tsx +++ b/src/app/(app)/article/[id]/page.tsx @@ -17,8 +17,9 @@ export default async function ArticlePage({ params }: PageProps<"/article/[id]"> // O service worker baixa artigos para leitura offline: isso não conta como leitura. const offlineSync = (await headers()).get(OFFLINE_SYNC_HEADER) === "1"; - if (!article.state?.isRead && !offlineSync) { - await db.userArticle.upsert({ + let state = article.state; + if (!state?.isRead && !offlineSync) { + state = await db.userArticle.upsert({ where: { userId_articleId: { userId: user.id, articleId: id } }, create: { userId: user.id, articleId: id, isRead: true, readAt: new Date() }, update: { isRead: true, readAt: new Date() }, @@ -30,5 +31,5 @@ export default async function ArticlePage({ params }: PageProps<"/article/[id]"> db.ttsProvider.count({ where: { enabled: true, OR: [{ userId: user.id }, { userId: null }] } }), ]); - return 0} ttsEnabled={ttsProviders > 0} />; + return 0} ttsEnabled={ttsProviders > 0} />; } diff --git a/src/app/(app)/article/[id]/standalone-reader.tsx b/src/app/(app)/article/[id]/standalone-reader.tsx index 72d0ef8..85f797c 100644 --- a/src/app/(app)/article/[id]/standalone-reader.tsx +++ b/src/app/(app)/article/[id]/standalone-reader.tsx @@ -16,7 +16,7 @@ export function StandaloneReader({ article: initial, aiEnabled, ttsEnabled }: { const state = article.state ?? { userId: "", articleId: article.id, - isRead: true, + isRead: false, isSaved: false, readAt: null, savedAt: null, diff --git a/src/app/actions/articles.ts b/src/app/actions/articles.ts index 3344640..aef1899 100644 --- a/src/app/actions/articles.ts +++ b/src/app/actions/articles.ts @@ -13,15 +13,16 @@ export async function openArticleAction(articleId: string) { const user = await requireUser(); const article = await getArticle(user.id, articleId); if (!article) return null; + let siblingIds: string[] = []; if (!article.state?.isRead) { await db.userArticle.upsert({ where: { userId_articleId: { userId: user.id, articleId } }, create: { userId: user.id, articleId, isRead: true, readAt: new Date() }, update: { isRead: true, readAt: new Date() }, }); - await markStorySiblingsRead(user.id, articleId); + siblingIds = await markStorySiblingsRead(user.id, articleId); } - return article; + return { article, siblingIds }; } async function assertAccess(userId: string, articleId: string) { @@ -42,9 +43,10 @@ export async function setRead(articleId: string, isRead: boolean) { create: { userId: user.id, articleId, isRead, readAt: isRead ? new Date() : null }, update: { isRead, readAt: isRead ? new Date() : null }, }); - if (isRead) await markStorySiblingsRead(user.id, articleId); + const siblingIds = isRead ? await markStorySiblingsRead(user.id, articleId) : []; // Sem revalidatePath: a lista já está atualizada no cliente e a barra lateral busca só os // contadores (/api/sidebar/counts). Revalidar remontava a página inteira a cada clique. + return { siblingIds }; } /** Marca vários como lidos de uma vez (rolagem da lista). Ignora ids sem acesso. */ @@ -55,7 +57,8 @@ export async function markReadMany(articleIds: string[]) { // Como em setRead: as outras fontes do mesmo fato (escondidas pelo agrupamento) também. Só dos // ids que o usuário pode ver (setArticleStates já ignora os demais). const allowed = await db.article.findMany({ where: { id: { in: ids }, ...accessibleArticles(user.id) }, select: { id: true } }); - await markStorySiblingsReadMany(user.id, allowed.map((a) => a.id)); + const siblingIds = await markStorySiblingsReadMany(user.id, allowed.map((a) => a.id)); + return { siblingIds }; } export async function setSaved(articleId: string, isSaved: boolean) { @@ -84,28 +87,7 @@ export async function markAllRead(scope: ArticleScope) { }); const ids = rows.map((row) => row.id); if (ids.length > 0) { - const now = new Date(); - for (let i = 0; i < ids.length; i += 500) { - const chunk = ids.slice(i, i + 500); - await db.$transaction(async (tx) => { - // Atualiza os estados existentes de uma vez e cria só os que faltam (em vez de um upsert por artigo). - await tx.userArticle.updateMany({ - where: { userId: user.id, articleId: { in: chunk } }, - data: { isRead: true, readAt: now }, - }); - const existing = await tx.userArticle.findMany({ - where: { userId: user.id, articleId: { in: chunk } }, - select: { articleId: true }, - }); - const known = new Set(existing.map((row) => row.articleId)); - const missing = chunk.filter((articleId) => !known.has(articleId)); - if (missing.length > 0) { - await tx.userArticle.createMany({ - data: missing.map((articleId) => ({ userId: user.id, articleId, isRead: true, readAt: now })), - }); - } - }); - } + await setArticleStates(user.id, ids, { isRead: true }); // Ação rara: invalida também o cache do roteador (o "Voltar" não mostra o estado antigo). revalidatePath("/", "layout"); } diff --git a/src/components/articles/article-workspace.tsx b/src/components/articles/article-workspace.tsx index 110768a..03aca08 100644 --- a/src/components/articles/article-workspace.tsx +++ b/src/components/articles/article-workspace.tsx @@ -72,7 +72,7 @@ export function ArticleWorkspace({ const listSize = useResizableWidth("openrss:list-width", { defaultWidth: 416, min: 280, max: 900, minContent: 420 }); const openRequest = useRef(0); const mutationRequest = useRef(new Map()); - const mutationQueue = useRef(new Map>()); + const mutationQueue = useRef(new Map>()); const filteredItems = useRef(new Map()); const loadingMoreRef = useRef(false); /** Marcados como não lidos pelo usuário: a rolagem (lido ao rolar) não os marca de novo. */ @@ -144,12 +144,25 @@ export function ArticleWorkspace({ setItems((list) => list.map((i) => (i.id === id ? { ...i, ...patch } : i))); }, []); - const persistInOrder = useCallback(async (key: string, operation: () => Promise) => { + const markSiblingsRead = useCallback((ids: string[]) => { + if (ids.length === 0) return; + const drop = new Set(ids); + setItems((list) => { + const next = list.map((item) => ({ + ...item, + isRead: drop.has(item.id) ? true : item.isRead, + related: item.related.map((source) => (drop.has(source.id) ? { ...source, isRead: true } : source)), + })); + return unreadOnly ? next.filter((item) => !drop.has(item.id)) : next; + }); + }, [unreadOnly]); + + const persistInOrder = useCallback(async (key: string, operation: () => Promise): Promise => { const previous = mutationQueue.current.get(key) ?? Promise.resolve(); const current = previous.catch(() => {}).then(operation); mutationQueue.current.set(key, current); try { - await current; + return await current; } finally { if (mutationQueue.current.get(key) === current) mutationQueue.current.delete(key); } @@ -160,20 +173,23 @@ export function ArticleWorkspace({ const request = ++openRequest.current; setLoadingId(id); setOperationError(null); - window.history.replaceState(null, "", buildUrl({ a: id })); try { - const article = await openArticleAction(id); - if (article) { + const opened = await openArticleAction(id); + if (opened) { + const { article, siblingIds } = opened; + window.history.replaceState(null, "", buildUrl({ a: id })); patchItem(id, { isRead: true }); + markSiblingsRead(siblingIds); if (!article.state?.isRead) refreshCounts(); } if (request !== openRequest.current) return; setLoadingId(null); - if (!article) { + if (!opened) { setOperationError(t("errors.notFound")); toast.error(t("errors.notFound")); return; } + const { article } = opened; const state = { ...(article.state ?? emptyState(article)), isRead: true, readAt: article.state?.readAt ?? new Date() }; setSelected({ ...article, state }); } catch { @@ -183,7 +199,7 @@ export function ArticleWorkspace({ toast.error(t("errors.openFailed")); } }, - [buildUrl, patchItem, refreshCounts, t], + [buildUrl, markSiblingsRead, patchItem, refreshCounts, t], ); const close = useCallback(() => { @@ -224,16 +240,19 @@ export function ArticleWorkspace({ next.splice(Math.min(removed.index, next.length), 0, { ...removed.item, isRead: false }); return next; }); - filteredItems.current.delete(key); } } else patchItem(id, { isRead }); if (selected?.id === id) setSelected((s) => (s ? { ...s, state: { ...(s.state ?? emptyState(s)), isRead } } : s)); try { - await persistInOrder(key, () => setRead(id, isRead)); + const { siblingIds } = await persistInOrder(key, () => setRead(id, isRead)); + if (isRead) markSiblingsRead(siblingIds); + if (unreadOnly && !isRead) filteredItems.current.delete(key); refreshCounts(); } catch { if (mutationRequest.current.get(key) !== request) return; - if (previousItem && unreadOnly && isRead) { + if (unreadOnly && !isRead && !previousItem) { + setItems((list) => list.filter((item) => item.id !== id)); + } else if (previousItem && unreadOnly && isRead) { setItems((list) => { const without = list.filter((item) => item.id !== id); without.splice(Math.min(previousIndex, without.length), 0, previousItem); @@ -247,7 +266,7 @@ export function ArticleWorkspace({ toast.error(t("errors.readStateFailed")); } }, - [items, patchItem, persistInOrder, refreshCounts, selected, t, unreadOnly], + [items, markSiblingsRead, patchItem, persistInOrder, refreshCounts, selected, t, unreadOnly], ); const toggleSaved = useCallback( @@ -270,17 +289,19 @@ export function ArticleWorkspace({ next.splice(Math.min(removed.index, next.length), 0, { ...removed.item, isSaved: true }); return next; }); - filteredItems.current.delete(key); } } else patchItem(id, { isSaved }); if (selected?.id === id) setSelected((s) => (s ? { ...s, state: { ...(s.state ?? emptyState(s)), isSaved } } : s)); try { await persistInOrder(key, () => setSaved(id, isSaved)); + if (scope.kind === "saved" && isSaved) filteredItems.current.delete(key); refreshCounts(); toast.success(isSaved ? t("saved") : t("unsaved")); } catch { if (mutationRequest.current.get(key) !== request) return; - if (previousItem && scope.kind === "saved" && !isSaved) { + if (scope.kind === "saved" && isSaved && !previousItem) { + setItems((list) => list.filter((item) => item.id !== id)); + } else if (previousItem && scope.kind === "saved" && !isSaved) { setItems((list) => { const without = list.filter((item) => item.id !== id); without.splice(Math.min(previousIndex, without.length), 0, previousItem); @@ -400,13 +421,16 @@ export function ArticleWorkspace({ pendingScrollReads.current.clear(); if (ids.length === 0) return; void markReadMany(ids) - .then(() => refreshCounts()) + .then(({ siblingIds }) => { + markSiblingsRead(siblingIds); + refreshCounts(); + }) .catch(() => { // Falhou: volta a mostrar como não lidos. const failed = new Set(ids); setItems((list) => list.map((i) => (failed.has(i.id) ? { ...i, isRead: false } : i))); }); - }, [refreshCounts]); + }, [markSiblingsRead, refreshCounts]); useEffect(() => () => { if (scrollReadTimer.current) clearTimeout(scrollReadTimer.current); flushScrollReads(); @@ -482,7 +506,7 @@ export function ArticleWorkspace({ if (selected?.url) window.open(selected.url, "_blank", "noopener"); break; case "m": - if (selected) void toggleRead(selected.id, !(selected.state?.isRead ?? true)); + if (selected) void toggleRead(selected.id, !(selected.state?.isRead ?? false)); break; case "s": if (selected) void toggleSaved(selected.id, !selected.state?.isSaved); @@ -635,7 +659,7 @@ export function ArticleWorkspace({ onClose={close} onPrev={selectedIndex > 0 ? () => move(-1) : undefined} onNext={selectedIndex !== -1 && selectedIndex < items.length - 1 ? () => move(1) : undefined} - onToggleRead={() => toggleRead(selected.id, !(selected.state?.isRead ?? true))} + onToggleRead={() => toggleRead(selected.id, !(selected.state?.isRead ?? false))} onToggleSaved={() => toggleSaved(selected.id, !selected.state?.isSaved)} /> diff --git a/src/components/articles/reader.tsx b/src/components/articles/reader.tsx index 89b9620..be9f333 100644 --- a/src/components/articles/reader.tsx +++ b/src/components/articles/reader.tsx @@ -107,7 +107,7 @@ export function Reader({ const widthConfig = READER_WIDTHS[readerWidth]; const nextWidth = READER_WIDTH_ORDER[(READER_WIDTH_ORDER.indexOf(readerWidth) + 1) % READER_WIDTH_ORDER.length]; - const isRead = article.state?.isRead ?? true; + const isRead = article.state?.isRead ?? false; const isSaved = article.state?.isSaved ?? false; const html = showFull && fullHtml ? fullHtml : article.contentHtml; diff --git a/src/lib/article-state.ts b/src/lib/article-state.ts index a501267..3cd4495 100644 --- a/src/lib/article-state.ts +++ b/src/lib/article-state.ts @@ -1,5 +1,5 @@ import "server-only"; -import { db } from "@/lib/db"; +import { db, getDbProvider } from "@/lib/db"; /** Só artigos de feeds que o usuário assina, ou que ele salvou. */ export function accessibleArticles(userId: string) { @@ -22,10 +22,21 @@ export async function setArticleStates(userId: string, articleIds: string[], cha for (let i = 0; i < ids.length; i += 500) { const chunk = ids.slice(i, i + 500); await db.$transaction(async (tx) => { + if (getDbProvider() === "postgresql") { + // Duas marcas ao mesmo tempo não derrubam a transação. O SQLite não aceita skipDuplicates; + // lá o escritor único já serializa. + await tx.userArticle.createMany({ + data: chunk.map((articleId) => ({ userId, articleId, ...data })), + // O client de tipos é o do SQLite, que recusa skipDuplicates; no Postgres o valor vale. + skipDuplicates: true as never, + }); + await tx.userArticle.updateMany({ where: { userId, articleId: { in: chunk } }, data }); + return; + } await tx.userArticle.updateMany({ where: { userId, articleId: { in: chunk } }, data }); const existing = await tx.userArticle.findMany({ where: { userId, articleId: { in: chunk } }, select: { articleId: true } }); - const known = new Set(existing.map((r) => r.articleId)); - const missing = chunk.filter((id) => !known.has(id)); + const known = new Set(existing.map((row) => row.articleId)); + const missing = chunk.filter((articleId) => !known.has(articleId)); if (missing.length) await tx.userArticle.createMany({ data: missing.map((articleId) => ({ userId, articleId, ...data })) }); }); } @@ -39,10 +50,10 @@ export async function markStorySiblingsRead(userId: string, articleId: string) { /** Versão em lote (rolagem): três consultas no total, não três por artigo. */ export async function markStorySiblingsReadMany(userId: string, articleIds: string[]) { - if (articleIds.length === 0) return 0; + if (articleIds.length === 0) return []; const read = await db.article.findMany({ where: { id: { in: articleIds }, storyId: { not: null } }, select: { storyId: true } }); const storyIds = [...new Set(read.map((a) => a.storyId!))]; - if (storyIds.length === 0) return 0; + if (storyIds.length === 0) return []; const siblings = await db.article.findMany({ where: { storyId: { in: storyIds }, @@ -52,5 +63,7 @@ export async function markStorySiblingsReadMany(userId: string, articleIds: stri }, select: { id: true }, }); - return setArticleStates(userId, siblings.map((s) => s.id), { isRead: true }); + const ids = siblings.map((s) => s.id); + if (ids.length) await setArticleStates(userId, ids, { isRead: true }); + return ids; }