Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions src/app/(app)/article/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand All @@ -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 <StandaloneReader article={{ ...article, state: article.state ? { ...article.state, isRead: true } : null }} aiEnabled={providers > 0} ttsEnabled={ttsProviders > 0} />;
return <StandaloneReader article={{ ...article, state }} aiEnabled={providers > 0} ttsEnabled={ttsProviders > 0} />;
}
2 changes: 1 addition & 1 deletion src/app/(app)/article/[id]/standalone-reader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
34 changes: 8 additions & 26 deletions src/app/actions/articles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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. */
Expand All @@ -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) {
Expand Down Expand Up @@ -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");
}
Expand Down
60 changes: 42 additions & 18 deletions src/components/articles/article-workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>());
const mutationQueue = useRef(new Map<string, Promise<void>>());
const mutationQueue = useRef(new Map<string, Promise<unknown>>());
const filteredItems = useRef(new Map<string, { item: ArticleListItem; index: number }>());
const loadingMoreRef = useRef(false);
/** Marcados como não lidos pelo usuário: a rolagem (lido ao rolar) não os marca de novo. */
Expand Down Expand Up @@ -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<void>) => {
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 <T,>(key: string, operation: () => Promise<T>): Promise<T> => {
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);
}
Expand All @@ -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 {
Expand All @@ -183,7 +199,7 @@ export function ArticleWorkspace({
toast.error(t("errors.openFailed"));
}
},
[buildUrl, patchItem, refreshCounts, t],
[buildUrl, markSiblingsRead, patchItem, refreshCounts, t],
);

const close = useCallback(() => {
Expand Down Expand Up @@ -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);
Expand All @@ -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(
Expand All @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)}
/>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/components/articles/reader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
25 changes: 19 additions & 6 deletions src/lib/article-state.ts
Original file line number Diff line number Diff line change
@@ -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) {
Expand All @@ -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 })) });
});
}
Expand All @@ -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 },
Expand All @@ -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;
}
Loading