diff --git a/apps/mobile/src/modules/entry-list/hooks.ts b/apps/mobile/src/modules/entry-list/hooks.ts index 06188176c..4437e4015 100644 --- a/apps/mobile/src/modules/entry-list/hooks.ts +++ b/apps/mobile/src/modules/entry-list/hooks.ts @@ -8,7 +8,10 @@ import type { NativeScrollEvent, NativeSyntheticEvent } from "react-native" import { useGeneralSettingKey } from "@/src/atoms/settings/general" -import { shouldCollectViewableItemsForMarkRead } from "./viewable-mark-read" +import { + getPendingMarkReadItems, + shouldCollectViewableItemsForMarkRead, +} from "./viewable-mark-read" const defaultIdExtractor = (item: ViewToken) => item.key export function useOnViewableItemsChanged({ @@ -95,10 +98,7 @@ export function useOnViewableItemsChanged({ const entryIds = lastRemovedItems.map((item) => stableIdExtractor(item)) const entryIdSet = new Set(entryIds) void unreadSyncService.queueEntriesAsRead(entryIds).then(() => { - setLastRemovedItems((prev) => { - if (!prev) return null - return prev.filter((prevItem) => !entryIdSet.has(stableIdExtractor(prevItem))) - }) + setLastRemovedItems((prev) => getPendingMarkReadItems(prev, entryIdSet, stableIdExtractor)) }) } diff --git a/apps/mobile/src/modules/entry-list/viewable-mark-read.test.ts b/apps/mobile/src/modules/entry-list/viewable-mark-read.test.ts index cf6f40600..0865dc73c 100644 --- a/apps/mobile/src/modules/entry-list/viewable-mark-read.test.ts +++ b/apps/mobile/src/modules/entry-list/viewable-mark-read.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from "vitest" -import { shouldCollectViewableItemsForMarkRead } from "./viewable-mark-read" +import { + getPendingMarkReadItems, + shouldCollectViewableItemsForMarkRead, +} from "./viewable-mark-read" describe("shouldCollectViewableItemsForMarkRead", () => { test("does not collect viewable items while disabled", () => { @@ -37,3 +40,30 @@ describe("shouldCollectViewableItemsForMarkRead", () => { ).toBe(false) }) }) + +describe("getPendingMarkReadItems", () => { + const getId = (item: { key: string }) => item.key + + test("keeps the items that were not handed off yet", () => { + const pending = { key: "b" } + + expect( + getPendingMarkReadItems([{ key: "a" }, pending, { key: "c" }], new Set(["a", "c"]), getId), + ).toEqual([pending]) + }) + + // The hook marks the stored items whenever they change, and marking entries that are already + // read resolves at once, so a new array for an unchanged or empty list would mark forever + test("is null once every handed-off item was marked", () => { + expect(getPendingMarkReadItems([{ key: "a" }], new Set(["a"]), getId)).toBeNull() + expect(getPendingMarkReadItems(null, new Set(["a"]), getId)).toBeNull() + }) + + test("returns the same list when none of its items was handed off", () => { + const items = [{ key: "a" }] + const empty: { key: string }[] = [] + + expect(getPendingMarkReadItems(items, new Set(["b"]), getId)).toBe(items) + expect(getPendingMarkReadItems(empty, new Set(["a"]), getId)).toBe(empty) + }) +}) diff --git a/apps/mobile/src/modules/entry-list/viewable-mark-read.ts b/apps/mobile/src/modules/entry-list/viewable-mark-read.ts index 7be7642ac..75cb98784 100644 --- a/apps/mobile/src/modules/entry-list/viewable-mark-read.ts +++ b/apps/mobile/src/modules/entry-list/viewable-mark-read.ts @@ -7,3 +7,21 @@ export const shouldCollectViewableItemsForMarkRead = ({ isScrollingDown: boolean offset: number }) => !disabled && isScrollingDown && offset > 0 + +/** + * The scrolled-out items still waiting to be marked read once `markedIds` were handed off. + * The result is stored in state that triggers the marking, so it keeps the same array when + * nothing changed and is null when nothing is left. Any new array would mark again, which + * resolves at once for entries that are already read, and so would loop without end. + */ +export const getPendingMarkReadItems = ( + items: T[] | null, + markedIds: ReadonlySet, + getId: (item: T) => string, +): T[] | null => { + if (!items) return null + + const pending = items.filter((item) => !markedIds.has(getId(item))) + if (pending.length === items.length) return items + return pending.length > 0 ? pending : null +}