fix: retry scroll mark-read after revisiting entries

This commit is contained in:
DIYgod 2026-05-26 20:19:04 +08:00
parent a52ff66fb3
commit dc43e39711
5 changed files with 84 additions and 45 deletions

View File

@ -0,0 +1,18 @@
import { unreadSyncService } from "@follow/store/unread/store"
import { describe, expect, it, vi } from "vitest"
import { batchMarkRead } from "./useEntryMarkReadHandler"
vi.mock("@follow/store/unread/store", () => ({
unreadSyncService: {
queueEntriesAsRead: vi.fn(),
},
}))
describe("batchMarkRead", () => {
it("queues ids without requiring entries to exist in the local store", () => {
batchMarkRead(["entry-1", "entry-2"])
expect(unreadSyncService.queueEntriesAsRead).toHaveBeenCalledWith(["entry-1", "entry-2"])
})
})

View File

@ -1,8 +1,7 @@
import { getView } from "@follow/constants"
import { entryActions } from "@follow/store/entry/store"
import { unreadSyncService } from "@follow/store/unread/store"
import type { Range } from "@tanstack/react-virtual"
import { useEffect, useMemo, useRef } from "react"
import { useMemo } from "react"
import { useEventCallback } from "usehooks-ts"
import { useGeneralSettingKey } from "~/atoms/settings/general"
@ -18,26 +17,13 @@ export const useEntryMarkReadHandler = (
const scrollMarkUnread = useGeneralSettingKey("scrollMarkUnread")
const feedView = useRouteParamsSelector((params) => params.view)
const processedEntryIds = useRef(new Set<string>())
useEffect(() => {
processedEntryIds.current.clear()
}, [entriesIds])
const handleRangeMarkRead = useEventCallback(
({ startIndex, endIndex }: Range, enabled?: boolean) => {
if (!enabled) return
const idSlice = entriesIds?.slice(startIndex, endIndex)
if (!idSlice) return
if (!idSlice?.length) return
// Filter out entries that have already been processed
const newEntries = idSlice.filter((id) => !processedEntryIds.current.has(id))
if (newEntries.length === 0) return
// Mark these entries as processed to avoid duplicate processing
newEntries.forEach((id) => processedEntryIds.current.add(id))
batchMarkRead(newEntries)
batchMarkRead(idSlice)
},
)
@ -71,19 +57,6 @@ export const useEntryMarkReadHandler = (
}
export function batchMarkRead(ids: string[]) {
const batchLikeIds = [] as string[]
const entriesId2Map = entryActions.getFlattenMapEntries()
for (const id of ids) {
const entry = entriesId2Map[id]
if (!entry) continue
const isRead = entry.read
if (!isRead && entry.feedId) {
batchLikeIds.push(id)
}
}
if (batchLikeIds.length > 0) {
void unreadSyncService.queueEntriesAsRead(batchLikeIds)
}
if (ids.length === 0) return
void unreadSyncService.queueEntriesAsRead(ids)
}

View File

@ -1,6 +1,6 @@
import { FeedViewType, getView } from "@follow/constants"
import { useScrollMarkReadGracePeriod, useTitle } from "@follow/hooks"
import { getScrollMarkReadRange } from "@follow/shared/scroll-mark-read"
import { getScrollMarkReadRangeState } from "@follow/shared/scroll-mark-read"
import { useEntry } from "@follow/store/entry/hooks"
import { useFeedById } from "@follow/store/feed/hooks"
import { useSubscriptionByFeedId } from "@follow/store/subscription/hooks"
@ -43,11 +43,11 @@ function EntryColumnContent() {
const state = useEntriesState()
const isInteracted = useRef(false)
const scrollMarkReadEndIndexRef = useRef<number | null>(null)
const scrollMarkReadAnchorIndexRef = useRef<number | null>(null)
const latestRangeStartIndexRef = useRef<number | null>(null)
const resetScrollInteractionState = useCallback(() => {
isInteracted.current = false
scrollMarkReadEndIndexRef.current = null
scrollMarkReadAnchorIndexRef.current = null
latestRangeStartIndexRef.current = null
}, [])
@ -133,19 +133,14 @@ function EntryColumnContent() {
(currentStartIndex: number) => {
if (!routeFeedId) return
const range = getScrollMarkReadRange({
previousEndIndex: scrollMarkReadEndIndexRef.current,
const { nextAnchorIndex, range } = getScrollMarkReadRangeState({
anchorIndex: scrollMarkReadAnchorIndexRef.current,
currentStartIndex,
})
scrollMarkReadAnchorIndexRef.current = nextAnchorIndex
if (range) {
handleScrollMarkRead?.(range as Range, isInteracted.current)
scrollMarkReadEndIndexRef.current = currentStartIndex
return
}
if (scrollMarkReadEndIndexRef.current === null) {
scrollMarkReadEndIndexRef.current = currentStartIndex
}
},
[handleScrollMarkRead, routeFeedId],
@ -182,8 +177,8 @@ function EntryColumnContent() {
}
latestRangeStartIndexRef.current = e.startIndex
if (scrollMarkReadEndIndexRef.current === null) {
scrollMarkReadEndIndexRef.current = e.startIndex
if (scrollMarkReadAnchorIndexRef.current === null) {
scrollMarkReadAnchorIndexRef.current = e.startIndex
} else if (isInteracted.current) {
flushScrollMarkRead(e.startIndex)
}

View File

@ -4,6 +4,7 @@ import {
getScrollMarkReadEndPadding,
getScrollMarkReadExitedSliceEnd,
getScrollMarkReadRange,
getScrollMarkReadRangeState,
MIN_SCROLL_MARK_READ_END_PADDING,
SCROLL_MARK_READ_END_INDICATOR_HEIGHT,
shouldRenderScrollMarkReadEndSpacer,
@ -63,4 +64,26 @@ describe("scroll mark-read range", () => {
}),
).toBeNull()
})
it("moves the anchor backward while scrolling up so entries can be retried", () => {
expect(
getScrollMarkReadRangeState({
anchorIndex: 12,
currentStartIndex: 8,
}),
).toEqual({
nextAnchorIndex: 8,
range: null,
})
expect(
getScrollMarkReadRangeState({
anchorIndex: 8,
currentStartIndex: 12,
}),
).toEqual({
nextAnchorIndex: 12,
range: { startIndex: 8, endIndex: 12 },
})
})
})

View File

@ -46,6 +46,36 @@ export const getScrollMarkReadRange = ({
}
}
export const getScrollMarkReadRangeState = ({
anchorIndex,
currentStartIndex,
}: {
anchorIndex: number | null | undefined
currentStartIndex: number | null | undefined
}) => {
if (
typeof currentStartIndex !== "number" ||
!Number.isInteger(currentStartIndex) ||
currentStartIndex < 0
) {
return {
nextAnchorIndex:
typeof anchorIndex === "number" && Number.isInteger(anchorIndex) && anchorIndex >= 0
? anchorIndex
: null,
range: null,
}
}
return {
nextAnchorIndex: currentStartIndex,
range: getScrollMarkReadRange({
previousEndIndex: anchorIndex,
currentStartIndex,
}),
}
}
export const getScrollMarkReadExitedSliceEnd = ({
indexes,
renderedEndIndex,