feat: render mark as read (#111)

* feat: render mark as read

Signed-off-by: Innei <i@innei.in>

* fix: mark read when active

Signed-off-by: Innei <i@innei.in>

* fix: default

Signed-off-by: Innei <i@innei.in>

---------

Signed-off-by: Innei <i@innei.in>
This commit is contained in:
Innei 2024-07-05 20:04:21 +08:00 committed by GitHub
parent 296c285a5d
commit 246efa2265
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 279 additions and 203 deletions

View File

@ -34,5 +34,6 @@
],
"cSpell.words": [
"rsshub"
]
],
"editor.foldingImportsByDefault": true
}

View File

@ -5,9 +5,12 @@ import { createSettingAtom } from "./helper"
const createDefaultSettings = () => ({
dataPersist: true,
// view
unreadOnly: false,
// mark unread
scrollMarkUnread: true,
hoverMarkUnread: true,
hoverMarkUnread: false,
renderMarkUnread: true,
})
export const {
useSettingKey: useGeneralSettingKey,

View File

@ -1,5 +1,5 @@
import { apiClient } from "@renderer/lib/api-fetch"
import { entryActions } from "@renderer/store/entry"
import { entryActions, getEntry } from "@renderer/store/entry"
import { create, keyResolver, windowScheduler } from "@yornaath/batshit"
type EntryId = string
@ -7,13 +7,17 @@ type FeedId = string
const unread = create({
fetcher: async (ids: [FeedId, EntryId][]) => {
await apiClient.reads.$post({ json: { entryIds: ids.map((i) => i[1]) } })
for (const [feedId, entryId] of ids) {
entryActions.markRead(feedId, entryId, true)
}
return []
},
resolver: keyResolver("id"),
scheduler: windowScheduler(1000),
})
export const batchMarkUnread = unread.fetch
export const batchMarkUnread = (...args: Parameters<typeof unread.fetch>) => {
const [, entryId] = args[0]
const currentIsRead = getEntry(entryId)?.read
if (currentIsRead) return
entryActions.markRead(args[0][0], args[0][1], true)
return unread.fetch.apply(null, args)
}

View File

@ -0,0 +1,158 @@
import { useGeneralSettingKey } from "@renderer/atoms/settings/general"
import {
useRouteParamsSelector,
useRouteParms,
} from "@renderer/hooks/biz/useRouteParams"
import { levels, ROUTE_FEED_PENDING, views } from "@renderer/lib/constants"
import { shortcuts } from "@renderer/lib/shortcuts"
import { useEntries } from "@renderer/queries/entries"
import { entryActions, useEntryIdsByFeedIdOrView } from "@renderer/store/entry"
import { useFolderFeedsByFeedId } from "@renderer/store/subscription"
import { useCallback, useEffect, useMemo, useRef } from "react"
import { useHotkeys } from "react-hotkeys-hook"
import type { ListRange } from "react-virtuoso"
import { useDebounceCallback } from "usehooks-ts"
import { batchMarkUnread } from "./helper"
export const useEntryMarkReadHandler = (entriesIds: string[]) => {
const renderAsRead = useGeneralSettingKey("renderMarkUnread")
const scrollMarkUnread = useGeneralSettingKey("scrollMarkUnread")
const feedView = useRouteParamsSelector((params) => params.view)
const handleMarkReadInRange = useDebounceCallback(
async ({ startIndex }: ListRange) => {
const idSlice = entriesIds?.slice(0, startIndex)
if (!idSlice) return
batchMarkRead(idSlice)
},
1000,
{ leading: false },
)
const handleRenderAsRead = useCallback(
async ({ startIndex, endIndex }: ListRange) => {
const idSlice = entriesIds?.slice(startIndex, endIndex)
if (!idSlice) return
batchMarkRead(idSlice)
},
[entriesIds],
)
return useMemo(() => {
if (views[feedView].wideMode && renderAsRead) {
return handleRenderAsRead
}
if (scrollMarkUnread) {
return handleMarkReadInRange
}
return
}, [
feedView,
handleMarkReadInRange,
handleRenderAsRead,
renderAsRead,
scrollMarkUnread,
])
}
export const useEntriesByView = () => {
const routeParams = useRouteParms()
const unreadOnly = useGeneralSettingKey("unreadOnly")
const { level, feedId, view } = routeParams
const folderIds = useFolderFeedsByFeedId(feedId)
const query = useEntries({
level,
id: level === levels.folder ? folderIds?.join(",") : feedId,
view,
...(unreadOnly === true && { read: false }),
})
const entries = useEntryIdsByFeedIdOrView(
feedId === ROUTE_FEED_PENDING ? view : feedId!,
{
unread: unreadOnly,
view,
},
)
useHotkeys(
shortcuts.entries.refetch.key,
() => {
query.refetch()
},
{ scopes: ["home"] },
)
// in unread only entries only can grow the data, but not shrink
// so we memo this previous data to avoid the flicker
const prevEntries = useRef(entries)
useEffect(() => {
prevEntries.current = []
}, [routeParams.feedId, routeParams.view])
const localEntries = useMemo(() => {
if (!unreadOnly) {
prevEntries.current = []
return entries
}
if (!prevEntries.current) {
prevEntries.current = entries
return entries
}
if (entries.length > prevEntries.current.length) {
prevEntries.current = entries
return entries
}
// merge the new entries with the old entries, and unique them
const nextIds = [...new Set([...prevEntries.current, ...entries])]
prevEntries.current = nextIds
return nextIds
}, [entries, prevEntries, unreadOnly])
const remoteEntryIds = useMemo(
() =>
query.data ?
query.data.pages.reduce((acc, page) => {
if (!page.data) return acc
acc.push(...page.data.map((entry) => entry.entries.id))
return acc
}, [] as string[]) :
null,
[query.data],
)
return {
...query,
// If remote data is not available, we use the local data, get the local data length
totalCount: query.data?.pages?.[0]?.total ?? localEntries.length,
entriesIds:
// NOTE: if we use the remote data, priority will be given to the remote data, local data maybe had sort issue
remoteEntryIds ?? localEntries,
}
}
function batchMarkRead(ids: string[]) {
const batchLikeIds = [] as [string, string][]
const entriesId2Map = entryActions.getFlattenMapEntries()
for (const id of ids) {
const entry = entriesId2Map[id]
if (!entry) continue
const isRead = entry.read
if (!isRead) {
batchLikeIds.push([entry.feeds.id, id])
}
}
if (batchLikeIds.length > 0) {
for (const [feedId, id] of batchLikeIds) {
batchMarkUnread([feedId, id])
}
}
}

View File

@ -1,5 +1,8 @@
import { useMainContainerElement } from "@renderer/atoms/dom"
import { useGeneralSettingKey } from "@renderer/atoms/settings/general"
import {
setGeneralSetting,
useGeneralSettingKey,
} from "@renderer/atoms/settings/general"
import { useUser } from "@renderer/atoms/user"
import { m } from "@renderer/components/common/Motion"
import { EmptyIcon } from "@renderer/components/icons/empty"
@ -13,7 +16,6 @@ import {
PopoverTrigger,
} from "@renderer/components/ui/popover"
import { EllipsisHorizontalTextWithTooltip } from "@renderer/components/ui/typography"
import { useRead } from "@renderer/hooks/biz/useEntryActions"
import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry"
import {
useRouteEntryId,
@ -29,26 +31,17 @@ import {
ROUTE_FEED_PENDING,
views,
} from "@renderer/lib/constants"
import { getStorageNS } from "@renderer/lib/ns"
import { shortcuts } from "@renderer/lib/shortcuts"
import { cn, getEntriesParams, getOS, isBizId } from "@renderer/lib/utils"
import { EntryHeader } from "@renderer/modules/entry-content/header"
import { useEntries } from "@renderer/queries/entries"
import { useRefreshFeedMutation } from "@renderer/queries/feed"
import { entryActions } from "@renderer/store/entry"
import {
useEntry,
useEntryIdsByFeedIdOrView,
} from "@renderer/store/entry/hooks"
import { entryActions, useEntry } from "@renderer/store/entry"
import { useFeedById, useFeedHeaderTitle } from "@renderer/store/feed"
import {
subscriptionActions,
useFolderFeedsByFeedId,
} from "@renderer/store/subscription"
import type { HTMLMotionProps } from "framer-motion"
import { useAtom, useAtomValue } from "jotai"
import { atomWithStorage } from "jotai/utils"
import { debounce } from "lodash-es"
import type { FC } from "react"
import {
forwardRef,
@ -60,65 +53,27 @@ import {
useState,
} from "react"
import { useHotkeys } from "react-hotkeys-hook"
import type { ListRange, VirtuosoHandle, VirtuosoProps } from "react-virtuoso"
import type { VirtuosoHandle, VirtuosoProps } from "react-virtuoso"
import { Virtuoso, VirtuosoGrid } from "react-virtuoso"
import { useEventCallback } from "usehooks-ts"
import { batchMarkUnread } from "./helper"
import { useEntriesByView, useEntryMarkReadHandler } from "./hooks"
import { EntryItem } from "./item"
const unreadOnlyAtom = atomWithStorage<boolean>(
getStorageNS("entry-unreadonly"),
true,
undefined,
{
getOnInit: true,
},
)
export function EntryColumn() {
const entries = useEntriesByView()
const { entriesIds, isFetchingNextPage } = entries
const { entryId: activeEntryId, view, feedId } = useRouteParms()
const activeEntry = useEntry(activeEntryId)
const markReadMutation = useRead()
useEffect(() => {
if (!activeEntry || activeEntry.read) {
return
}
markReadMutation.mutate(activeEntry)
}, [activeEntry?.entries?.id, activeEntry?.read])
if (!feedId || !activeEntryId) return
const handleMarkreadInRange = useEventCallback(
debounce(
async ({ startIndex }: ListRange) => {
const idSlice = entriesIds?.slice(0, startIndex)
batchMarkUnread([feedId, activeEntryId])
}, [activeEntry, activeEntryId, feedId])
if (!idSlice) return
const batchLikeIds = [] as [string, string][]
const entriesId2Map = entryActions.getFlattenMapEntries()
for (const id of idSlice) {
const entry = entriesId2Map[id]
if (!entry) continue
const isRead = entry.read
if (!isRead) {
batchLikeIds.push([entry.feeds.id, id])
}
}
if (batchLikeIds.length > 0) {
for (const [feedId, id] of batchLikeIds) {
batchMarkUnread([feedId, id])
}
}
},
1000,
{ leading: false },
),
)
const scrollMarkUnread = useGeneralSettingKey("scrollMarkUnread")
const handleMarkReadInRange = useEntryMarkReadHandler(entriesIds)
const virtuosoOptions = {
components: {
@ -133,7 +88,7 @@ export function EntryColumn() {
)
}, [isFetchingNextPage]),
},
rangeChanged: scrollMarkUnread ? handleMarkreadInRange : undefined,
rangeChanged: handleMarkReadInRange,
totalCount: entries.totalCount,
endReached: () => entries.hasNextPage && entries.fetchNextPage(),
data: entriesIds,
@ -182,90 +137,12 @@ export function EntryColumn() {
)
}
const useEntriesByView = () => {
const routeParams = useRouteParms()
const unreadOnly = useAtomValue(unreadOnlyAtom)
const { level, feedId, view } = routeParams
const folderIds = useFolderFeedsByFeedId(feedId)
const query = useEntries({
level,
id: level === levels.folder ? folderIds?.join(",") : feedId,
view,
...(unreadOnly === true && { read: false }),
})
const entries = useEntryIdsByFeedIdOrView(
feedId === ROUTE_FEED_PENDING ? view : feedId!,
{
unread: unreadOnly,
view,
},
)
useHotkeys(
shortcuts.entries.refetch.key,
() => {
query.refetch()
},
{ scopes: ["home"] },
)
// in unread only entries only can grow the data, but not shrink
// so we memo this previous data to avoid the flicker
const prevEntries = useRef(entries)
useEffect(() => {
prevEntries.current = []
}, [routeParams.feedId, routeParams.view])
const localEntries = useMemo(() => {
if (!unreadOnly) {
prevEntries.current = []
return entries
}
if (!prevEntries.current) {
prevEntries.current = entries
return entries
}
if (entries.length > prevEntries.current.length) {
prevEntries.current = entries
return entries
}
// merge the new entries with the old entries, and unique them
const nextIds = [...new Set([...prevEntries.current, ...entries])]
prevEntries.current = nextIds
return nextIds
}, [entries, prevEntries, unreadOnly])
const remoteEntryIds = useMemo(
() =>
query.data ?
query.data.pages.reduce((acc, page) => {
if (!page.data) return acc
acc.push(...page.data.map((entry) => entry.entries.id))
return acc
}, [] as string[]) :
null,
[query.data],
)
return {
...query,
// If remote data is not available, we use the local data, get the local data length
totalCount: query.data?.pages?.[0]?.total ?? localEntries.length,
entriesIds:
// NOTE: if we use the remote data, priority will be given to the remote data, local data maybe had sort issue
remoteEntryIds ?? localEntries,
}
}
const ListHeader: FC<{
totalCount: number
}> = ({ totalCount }) => {
const routerParams = useRouteParms()
const [unreadOnly, setUnreadOnly] = useAtom(unreadOnlyAtom)
const unreadOnly = useGeneralSettingKey("unreadOnly")
const { feedId, entryId, view } = routerParams
const folderIds = useFolderFeedsByFeedId(feedId)
@ -377,7 +254,7 @@ const ListHeader: FC<{
<ActionButton
tooltip={unreadOnly ? "Unread Only" : "All"}
shortcut={shortcuts.entries.toggleUnreadOnly.key}
onClick={() => setUnreadOnly(!unreadOnly)}
onClick={() => setGeneralSetting("unreadOnly", !unreadOnly)}
>
{unreadOnly ? (
<i className="i-mgc-round-cute-fi" />
@ -421,8 +298,7 @@ const ListContent = forwardRef<HTMLDivElement>((props, ref) => (
const EmptyList = forwardRef<HTMLDivElement, HTMLMotionProps<"div">>(
(props, ref) => {
const unreadOnly = useAtomValue(unreadOnlyAtom)
const unreadOnly = useGeneralSettingKey("unreadOnly")
return (
<m.div
className="-mt-6 flex h-full flex-col items-center justify-center gap-2 text-zinc-400"

View File

@ -13,6 +13,7 @@ import { Queries } from "@renderer/queries"
import { useEntry } from "@renderer/store/entry/hooks"
import type { FC } from "react"
import { memo, useCallback } from "react"
import { useDebounceCallback } from "usehooks-ts"
import { ReactVirtuosoItemPlaceholder } from "../../components/ui/placeholder"
import { ArticleItem } from "./article-item"
@ -60,12 +61,18 @@ function EntryItemImpl({
const asRead = useAsRead(entry)
const hoverMarkUnread = useGeneralSettingKey("hoverMarkUnread")
const handleMouseEnter = useCallback(() => {
if (!hoverMarkUnread) return
if (asRead) return
const handleMouseEnter = useDebounceCallback(
() => {
if (!hoverMarkUnread) return
if (asRead) return
batchMarkUnread([entry.feeds.id, entry.entries.id])
}, [asRead, entry.entries.id, entry.feeds.id, hoverMarkUnread])
batchMarkUnread([entry.feeds.id, entry.entries.id])
},
233,
{
leading: false,
},
)
let Item: FC<UniversalItemProps>
switch (view) {

View File

@ -0,0 +1,5 @@
Mark as read has three logic now.
- Rendering entry as read.
- Hover entry as read
- Scrolling entry as read

View File

@ -36,6 +36,16 @@ export const SettingGeneral = () => {
/>
)}
<SettingSectionTitle title="view" />
<SettingSwitch
checked={settings.unreadOnly}
onCheckedChange={(checked) =>
setGeneralSetting("unreadOnly", checked)}
label="Show unread content initially"
/>
<SettingDescription>
Only show unread content initially when you open the app
</SettingDescription>
<SettingSectionTitle title="Mark read" />
<SettingSwitch
@ -45,7 +55,7 @@ export const SettingGeneral = () => {
label="Mark as read when scrolling"
/>
<SettingDescription>
Automatic marking of feed entry as read when the item is scrolled up
Automatic marking of feed entries as read when the item is scrolled up
out of the viewport.
</SettingDescription>
@ -57,7 +67,19 @@ export const SettingGeneral = () => {
label="Mark as read when hovering"
/>
<SettingDescription>
Automatic marking of feed entry as read when the item is hovered.
Automatic marking of feed entries as read when the item is hovered.
</SettingDescription>
<SettingSwitch
className="mt-6"
checked={settings.renderMarkUnread}
onCheckedChange={(checked) =>
setGeneralSetting("renderMarkUnread", checked)}
label="Mark as read when in the viewport"
/>
<SettingDescription>
Automatically mark feed entries with only one level of content(e.g. Social Media, Picture, Video views) as read when
the item is in the viewport.
</SettingDescription>
<SettingSectionTitle title="Data control" />

View File

@ -231,3 +231,5 @@ export const useEntryStore = createZustandStore<EntryState & EntryActions>(
}))
export const entryActions = getStoreActions(useEntryStore)
export const getEntry = (entryId: string) => useEntryStore.getState().flatMapEntries[entryId]

View File

@ -0,0 +1,44 @@
.mask-both {
mask-image: linear-gradient(
rgba(255, 255, 255, 0) 0%,
rgb(255, 255, 255) 20px,
rgb(255, 255, 255) calc(100% - 20px),
rgba(255, 255, 255, 0) 100%
);
}
.mask-both-lg {
mask-image: linear-gradient(
rgba(255, 255, 255, 0) 0%,
rgb(255, 255, 255) 50px,
rgb(255, 255, 255) calc(100% - 50px),
rgba(255, 255, 255, 0) 100%
);
}
.mask-b {
mask-image: linear-gradient(
rgb(255, 255, 255) calc(100% - 20px),
rgba(255, 255, 255, 0) 100%
);
}
.mask-b-lg {
mask-image: linear-gradient(
rgb(255, 255, 255) calc(100% - 50px),
rgba(255, 255, 255, 0) 100%
);
}
.mask-t {
mask-image: linear-gradient(
rgba(255, 255, 255, 0) 0%,
rgb(255, 255, 255) 20px
);
}
.mask-t-lg {
mask-image: linear-gradient(
rgba(255, 255, 255, 0) 0%,
rgb(255, 255, 255) 50px
);
}

View File

@ -2,3 +2,4 @@
@import "./font.css";
@import "./base.css";
@import "./colors.css";
@import "./additional.css";

View File

@ -229,50 +229,3 @@
rgba(0, 0, 0, 0.067) 0px 2px 5px, rgba(0, 0, 0, 0.067) 0px 1px 1px;
}
}
/* Container Mask */
@layer components {
.mask-both {
mask-image: linear-gradient(
rgba(255, 255, 255, 0) 0%,
rgb(255, 255, 255) 20px,
rgb(255, 255, 255) calc(100% - 20px),
rgba(255, 255, 255, 0) 100%
);
}
.mask-both-lg {
mask-image: linear-gradient(
rgba(255, 255, 255, 0) 0%,
rgb(255, 255, 255) 50px,
rgb(255, 255, 255) calc(100% - 50px),
rgba(255, 255, 255, 0) 100%
);
}
.mask-b {
mask-image: linear-gradient(
rgb(255, 255, 255) calc(100% - 20px),
rgba(255, 255, 255, 0) 100%
);
}
.mask-b-lg {
mask-image: linear-gradient(
rgb(255, 255, 255) calc(100% - 50px),
rgba(255, 255, 255, 0) 100%
);
}
.mask-t {
mask-image: linear-gradient(
rgba(255, 255, 255, 0) 0%,
rgb(255, 255, 255) 20px
);
}
.mask-t-lg {
mask-image: linear-gradient(
rgba(255, 255, 255, 0) 0%,
rgb(255, 255, 255) 50px
);
}
}