fix: batch scroll mark-read requests
This commit is contained in:
parent
27b30e0d47
commit
07cbf35de7
|
|
@ -84,8 +84,6 @@ export function batchMarkRead(ids: string[]) {
|
|||
}
|
||||
|
||||
if (batchLikeIds.length > 0) {
|
||||
for (const id of batchLikeIds) {
|
||||
unreadSyncService.markEntryAsRead(id)
|
||||
}
|
||||
void unreadSyncService.queueEntriesAsRead(batchLikeIds)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { FeedViewType, getView } from "@follow/constants"
|
||||
import { useScrollMarkReadGracePeriod, useTitle } from "@follow/hooks"
|
||||
import { getScrollMarkReadRange } 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"
|
||||
|
|
@ -42,10 +43,12 @@ function EntryColumnContent() {
|
|||
const state = useEntriesState()
|
||||
|
||||
const isInteracted = useRef(false)
|
||||
const rangeQueueRef = useRef<Range[]>([])
|
||||
const scrollMarkReadEndIndexRef = useRef<number | null>(null)
|
||||
const latestRangeStartIndexRef = useRef<number | null>(null)
|
||||
const resetScrollInteractionState = useCallback(() => {
|
||||
isInteracted.current = false
|
||||
rangeQueueRef.current = []
|
||||
scrollMarkReadEndIndexRef.current = null
|
||||
latestRangeStartIndexRef.current = null
|
||||
}, [])
|
||||
|
||||
const actions = useEntriesActions()
|
||||
|
|
@ -126,24 +129,37 @@ function EntryColumnContent() {
|
|||
pauseScrollMarkRead,
|
||||
})
|
||||
|
||||
const flushScrollMarkRead = useCallback(
|
||||
(currentStartIndex: number) => {
|
||||
if (!routeFeedId) return
|
||||
|
||||
const range = getScrollMarkReadRange({
|
||||
previousEndIndex: scrollMarkReadEndIndexRef.current,
|
||||
currentStartIndex,
|
||||
})
|
||||
|
||||
if (range) {
|
||||
handleScrollMarkRead?.(range as Range, isInteracted.current)
|
||||
scrollMarkReadEndIndexRef.current = currentStartIndex
|
||||
return
|
||||
}
|
||||
|
||||
if (scrollMarkReadEndIndexRef.current === null) {
|
||||
scrollMarkReadEndIndexRef.current = currentStartIndex
|
||||
}
|
||||
},
|
||||
[handleScrollMarkRead, routeFeedId],
|
||||
)
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!isInteracted.current) {
|
||||
isInteracted.current = true
|
||||
}
|
||||
|
||||
if (!routeFeedId) return
|
||||
|
||||
const [first, second] = rangeQueueRef.current
|
||||
if (first && second && second.startIndex - first.startIndex > 0) {
|
||||
handleScrollMarkRead?.(
|
||||
{
|
||||
startIndex: first.startIndex,
|
||||
endIndex: second.startIndex,
|
||||
} as Range,
|
||||
isInteracted.current,
|
||||
)
|
||||
if (latestRangeStartIndexRef.current !== null) {
|
||||
flushScrollMarkRead(latestRangeStartIndexRef.current)
|
||||
}
|
||||
}, [handleScrollMarkRead, routeFeedId])
|
||||
}, [flushScrollMarkRead])
|
||||
|
||||
const { handleScroll: handleScrollBeyond } = useAttachScrollBeyond()
|
||||
const handleCombinedScroll = useCallback(
|
||||
|
|
@ -161,13 +177,15 @@ function EntryColumnContent() {
|
|||
const renderAsRead = useGeneralSettingKey("renderMarkUnread")
|
||||
const handleRangeChange = useCallback(
|
||||
(e: Range) => {
|
||||
const [_, second] = rangeQueueRef.current
|
||||
if (second?.startIndex === e.startIndex) {
|
||||
if (latestRangeStartIndexRef.current === e.startIndex) {
|
||||
return
|
||||
}
|
||||
rangeQueueRef.current.push(e)
|
||||
if (rangeQueueRef.current.length > 2) {
|
||||
rangeQueueRef.current.shift()
|
||||
|
||||
latestRangeStartIndexRef.current = e.startIndex
|
||||
if (scrollMarkReadEndIndexRef.current === null) {
|
||||
scrollMarkReadEndIndexRef.current = e.startIndex
|
||||
} else if (isInteracted.current) {
|
||||
flushScrollMarkRead(e.startIndex)
|
||||
}
|
||||
|
||||
if (!renderAsRead) return
|
||||
|
|
@ -177,7 +195,7 @@ function EntryColumnContent() {
|
|||
// For gird, render as mark read logic
|
||||
handleRenderMarkRead?.(e, isInteracted.current)
|
||||
},
|
||||
[handleRenderMarkRead, renderAsRead, view],
|
||||
[flushScrollMarkRead, handleRenderMarkRead, renderAsRead, view],
|
||||
)
|
||||
|
||||
const fetchNextPage = useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -78,23 +78,20 @@ export function useOnViewableItemsChanged({
|
|||
if (disabled) return
|
||||
|
||||
if (isLoggedIn && markAsReadWhenScrolling && !pauseScrollMarkRead && lastRemovedItems) {
|
||||
lastRemovedItems.forEach((item) => {
|
||||
unreadSyncService.markEntryAsRead(stableIdExtractor(item)).then(() => {
|
||||
setLastRemovedItems((prev) => {
|
||||
if (prev) {
|
||||
return prev.filter((prevItem) => prevItem.key !== item.key)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
})
|
||||
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)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (isLoggedIn && markAsReadWhenRendering && lastViewableItems) {
|
||||
lastViewableItems.forEach((item) => {
|
||||
unreadSyncService.markEntryAsRead(stableIdExtractor(item))
|
||||
})
|
||||
void unreadSyncService.queueEntriesAsRead(
|
||||
lastViewableItems.map((item) => stableIdExtractor(item)),
|
||||
)
|
||||
}
|
||||
}, [
|
||||
disabled,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"
|
|||
import {
|
||||
getScrollMarkReadEndPadding,
|
||||
getScrollMarkReadExitedSliceEnd,
|
||||
getScrollMarkReadRange,
|
||||
MIN_SCROLL_MARK_READ_END_PADDING,
|
||||
SCROLL_MARK_READ_END_INDICATOR_HEIGHT,
|
||||
shouldRenderScrollMarkReadEndSpacer,
|
||||
|
|
@ -43,3 +44,23 @@ describe("scroll mark-read exited slice", () => {
|
|||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("scroll mark-read range", () => {
|
||||
it("marks the full skipped range when scrolling jumps over intermediate entries", () => {
|
||||
expect(
|
||||
getScrollMarkReadRange({
|
||||
previousEndIndex: 4,
|
||||
currentStartIndex: 12,
|
||||
}),
|
||||
).toEqual({ startIndex: 4, endIndex: 12 })
|
||||
})
|
||||
|
||||
it("does not mark entries while scrolling upward or staying within the previous high-water mark", () => {
|
||||
expect(
|
||||
getScrollMarkReadRange({
|
||||
previousEndIndex: 12,
|
||||
currentStartIndex: 8,
|
||||
}),
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,6 +17,35 @@ export const shouldRenderScrollMarkReadEndSpacer = ({
|
|||
hasNextPage: boolean
|
||||
}) => entryCount > 0 && !hasNextPage
|
||||
|
||||
export const getScrollMarkReadRange = ({
|
||||
previousEndIndex,
|
||||
currentStartIndex,
|
||||
}: {
|
||||
previousEndIndex: number | null | undefined
|
||||
currentStartIndex: number | null | undefined
|
||||
}) => {
|
||||
if (
|
||||
typeof previousEndIndex !== "number" ||
|
||||
!Number.isInteger(previousEndIndex) ||
|
||||
previousEndIndex < 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
typeof currentStartIndex !== "number" ||
|
||||
!Number.isInteger(currentStartIndex) ||
|
||||
currentStartIndex <= previousEndIndex
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
startIndex: previousEndIndex,
|
||||
endIndex: currentStartIndex,
|
||||
}
|
||||
}
|
||||
|
||||
export const getScrollMarkReadExitedSliceEnd = ({
|
||||
indexes,
|
||||
renderedEndIndex,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,132 @@
|
|||
import { FeedViewType } from "@follow/constants"
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { apiContext } from "../../context"
|
||||
import type { FollowAPI } from "../../types"
|
||||
import { useEntryStore } from "../entry/store"
|
||||
import type { EntryModel } from "../entry/types"
|
||||
import { unreadSyncService, useUnreadStore } from "./store"
|
||||
|
||||
const { entryPatchManyMock, unreadUpsertManyMock } = vi.hoisted(() => ({
|
||||
entryPatchManyMock: vi.fn(),
|
||||
unreadUpsertManyMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("@follow/database/services/entry", () => ({
|
||||
EntryService: {
|
||||
patchMany: entryPatchManyMock,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock("@follow/database/services/unread", () => ({
|
||||
UnreadService: {
|
||||
getUnreadAll: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
upsertMany: unreadUpsertManyMock,
|
||||
},
|
||||
}))
|
||||
|
||||
const createEntry = (id: string, feedId: string, read = false): EntryModel => ({
|
||||
id,
|
||||
guid: `${id}-guid`,
|
||||
insertedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
publishedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
feedId,
|
||||
read,
|
||||
})
|
||||
|
||||
describe("unreadSyncService", () => {
|
||||
const markAsReadMock = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
useEntryStore.setState({
|
||||
data: {},
|
||||
entryIdByView: {
|
||||
[FeedViewType.All]: new Set(),
|
||||
[FeedViewType.Articles]: new Set(),
|
||||
[FeedViewType.Audios]: new Set(),
|
||||
[FeedViewType.Notifications]: new Set(),
|
||||
[FeedViewType.Pictures]: new Set(),
|
||||
[FeedViewType.SocialMedia]: new Set(),
|
||||
[FeedViewType.Videos]: new Set(),
|
||||
},
|
||||
entryIdByCategory: {},
|
||||
entryIdByFeed: {},
|
||||
entryIdByInbox: {},
|
||||
entryIdByList: {},
|
||||
entryIdSet: new Set(),
|
||||
})
|
||||
useUnreadStore.setState({ data: {} })
|
||||
apiContext.provide({
|
||||
reads: {
|
||||
markAsRead: markAsReadMock,
|
||||
},
|
||||
} as unknown as FollowAPI)
|
||||
})
|
||||
|
||||
it("marks multiple feed entries as read with one request and one local patch", async () => {
|
||||
const entries = {
|
||||
entry1: createEntry("entry1", "feed1"),
|
||||
entry2: createEntry("entry2", "feed1"),
|
||||
}
|
||||
useEntryStore.setState((state) => ({
|
||||
...state,
|
||||
data: entries,
|
||||
entryIdSet: new Set(Object.keys(entries)),
|
||||
}))
|
||||
useUnreadStore.setState({ data: { feed1: 2 } })
|
||||
markAsReadMock.mockResolvedValue({ data: null })
|
||||
|
||||
await unreadSyncService.markEntriesAsRead(["entry1", "entry2"])
|
||||
|
||||
expect(markAsReadMock).toHaveBeenCalledTimes(1)
|
||||
expect(markAsReadMock).toHaveBeenCalledWith({
|
||||
entryIds: ["entry1", "entry2"],
|
||||
isInbox: false,
|
||||
})
|
||||
expect(entryPatchManyMock).toHaveBeenCalledTimes(1)
|
||||
expect(entryPatchManyMock).toHaveBeenCalledWith({
|
||||
entry: { read: true },
|
||||
entryIds: ["entry1", "entry2"],
|
||||
})
|
||||
expect(useEntryStore.getState().data.entry1?.read).toBe(true)
|
||||
expect(useEntryStore.getState().data.entry2?.read).toBe(true)
|
||||
expect(useUnreadStore.getState().data.feed1).toBe(0)
|
||||
})
|
||||
|
||||
it("queues rapid read marks into one batched request", async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
try {
|
||||
const entries = {
|
||||
entry1: createEntry("entry1", "feed1"),
|
||||
entry2: createEntry("entry2", "feed1"),
|
||||
}
|
||||
useEntryStore.setState((state) => ({
|
||||
...state,
|
||||
data: entries,
|
||||
entryIdSet: new Set(Object.keys(entries)),
|
||||
}))
|
||||
useUnreadStore.setState({ data: { feed1: 2 } })
|
||||
markAsReadMock.mockResolvedValue({ data: null })
|
||||
|
||||
const firstFlush = unreadSyncService.queueEntriesAsRead(["entry1"])
|
||||
const secondFlush = unreadSyncService.queueEntriesAsRead(["entry2"])
|
||||
|
||||
expect(markAsReadMock).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await Promise.all([firstFlush, secondFlush])
|
||||
|
||||
expect(markAsReadMock).toHaveBeenCalledTimes(1)
|
||||
expect(markAsReadMock).toHaveBeenCalledWith({
|
||||
entryIds: ["entry1", "entry2"],
|
||||
isInbox: false,
|
||||
})
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -26,11 +26,22 @@ const initialUnreadStore: UnreadState = {
|
|||
data: {},
|
||||
}
|
||||
|
||||
const READ_MARK_BATCH_WINDOW = 100
|
||||
|
||||
export const useUnreadStore = createZustandStore<UnreadState>("unread")(() => initialUnreadStore)
|
||||
const get = useUnreadStore.getState
|
||||
const set = useUnreadStore.setState
|
||||
|
||||
type ReadEntryTarget = {
|
||||
entryId: string
|
||||
id: FeedIdOrInboxHandle
|
||||
isInbox: boolean
|
||||
}
|
||||
|
||||
class UnreadSyncService {
|
||||
private queuedReadEntryIds = new Set<string>()
|
||||
private queuedReadFlushPromise: Promise<void> | null = null
|
||||
|
||||
async resetFromRemote() {
|
||||
const res = await api().reads.get({})
|
||||
|
||||
|
|
@ -192,7 +203,114 @@ class UnreadSyncService {
|
|||
})
|
||||
}
|
||||
|
||||
private getReadEntryTargets(entryIds: string[]): ReadEntryTarget[] {
|
||||
const seenEntryIds = new Set<string>()
|
||||
const targets: ReadEntryTarget[] = []
|
||||
|
||||
for (const entryId of entryIds) {
|
||||
if (seenEntryIds.has(entryId)) continue
|
||||
seenEntryIds.add(entryId)
|
||||
|
||||
const entry = getEntry(entryId)
|
||||
if (!entry || entry.read || (!entry.feedId && !entry.inboxHandle)) continue
|
||||
|
||||
targets.push({
|
||||
entryId,
|
||||
id: entry.inboxHandle || entry.feedId || "",
|
||||
isInbox: !!entry.inboxHandle,
|
||||
})
|
||||
}
|
||||
|
||||
return targets
|
||||
}
|
||||
|
||||
async markEntriesAsRead(entryIds: string[]) {
|
||||
const targets = this.getReadEntryTargets(entryIds)
|
||||
if (targets.length === 0) return
|
||||
|
||||
const targetEntryIds = targets.map((target) => target.entryId)
|
||||
const unreadCountById = targets.reduce(
|
||||
(acc, target) => {
|
||||
acc[target.id] = (acc[target.id] || 0) + 1
|
||||
return acc
|
||||
},
|
||||
{} as Record<FeedIdOrInboxHandle, number>,
|
||||
)
|
||||
|
||||
const feedEntryIds = targets.filter((target) => !target.isInbox).map((target) => target.entryId)
|
||||
const inboxEntryIds = targets.filter((target) => target.isInbox).map((target) => target.entryId)
|
||||
|
||||
const tx = createTransaction()
|
||||
tx.store(() => {
|
||||
entryActions.markEntryReadStatusInSession({ entryIds: targetEntryIds, read: true })
|
||||
for (const [id, count] of Object.entries(unreadCountById)) {
|
||||
unreadActions.removeUnread(id, count)
|
||||
}
|
||||
})
|
||||
|
||||
tx.request(async () => {
|
||||
if (feedEntryIds.length > 0) {
|
||||
await api().reads.markAsRead({ entryIds: feedEntryIds, isInbox: false })
|
||||
}
|
||||
if (inboxEntryIds.length > 0) {
|
||||
await api().reads.markAsRead({ entryIds: inboxEntryIds, isInbox: true })
|
||||
}
|
||||
})
|
||||
|
||||
tx.rollback(() => {
|
||||
entryActions.markEntryReadStatusInSession({ entryIds: targetEntryIds, read: false })
|
||||
for (const [id, count] of Object.entries(unreadCountById)) {
|
||||
unreadActions.addUnread(id, count)
|
||||
}
|
||||
})
|
||||
|
||||
tx.persist(() => {
|
||||
return EntryService.patchMany({
|
||||
entry: { read: true },
|
||||
entryIds: targetEntryIds,
|
||||
})
|
||||
})
|
||||
|
||||
Object.keys(unreadCountById).forEach((id) => {
|
||||
if (id) {
|
||||
setFeedUnreadDirty(id)
|
||||
}
|
||||
})
|
||||
|
||||
await tx.run()
|
||||
}
|
||||
|
||||
queueEntriesAsRead(entryIds: string[]) {
|
||||
for (const entryId of entryIds) {
|
||||
this.queuedReadEntryIds.add(entryId)
|
||||
}
|
||||
|
||||
if (this.queuedReadFlushPromise) {
|
||||
return this.queuedReadFlushPromise
|
||||
}
|
||||
|
||||
this.queuedReadFlushPromise = new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
const queuedEntryIds = Array.from(this.queuedReadEntryIds)
|
||||
this.queuedReadEntryIds.clear()
|
||||
this.queuedReadFlushPromise = null
|
||||
|
||||
this.markEntriesAsRead(queuedEntryIds)
|
||||
.catch((error) => {
|
||||
console.error(error)
|
||||
})
|
||||
.finally(resolve)
|
||||
}, READ_MARK_BATCH_WINDOW)
|
||||
})
|
||||
|
||||
return this.queuedReadFlushPromise
|
||||
}
|
||||
|
||||
private async markEntryReadStatus({ entryId, read }: { entryId: string; read: boolean }) {
|
||||
if (read) {
|
||||
return this.markEntriesAsRead([entryId])
|
||||
}
|
||||
|
||||
const entry = getEntry(entryId)
|
||||
if (!entry || entry.read === read || (!entry.feedId && !entry.inboxHandle)) return
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue