diff --git a/.vscode/settings.json b/.vscode/settings.json index c28bad1695..74bc17a471 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -34,5 +34,6 @@ ], "cSpell.words": [ "rsshub" - ] + ], + "editor.foldingImportsByDefault": true } diff --git a/src/renderer/src/atoms/settings/general.ts b/src/renderer/src/atoms/settings/general.ts index 495133b77a..2b67861c63 100644 --- a/src/renderer/src/atoms/settings/general.ts +++ b/src/renderer/src/atoms/settings/general.ts @@ -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, diff --git a/src/renderer/src/modules/entry-column/helper.ts b/src/renderer/src/modules/entry-column/helper.ts index d415bf110a..03d9a85e90 100644 --- a/src/renderer/src/modules/entry-column/helper.ts +++ b/src/renderer/src/modules/entry-column/helper.ts @@ -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) => { + 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) +} diff --git a/src/renderer/src/modules/entry-column/hooks.ts b/src/renderer/src/modules/entry-column/hooks.ts new file mode 100644 index 0000000000..0d2a91ee25 --- /dev/null +++ b/src/renderer/src/modules/entry-column/hooks.ts @@ -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]) + } + } +} diff --git a/src/renderer/src/modules/entry-column/index.tsx b/src/renderer/src/modules/entry-column/index.tsx index a28a22f1e3..f4aa81e8bf 100644 --- a/src/renderer/src/modules/entry-column/index.tsx +++ b/src/renderer/src/modules/entry-column/index.tsx @@ -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( - 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]) - - const handleMarkreadInRange = useEventCallback( - debounce( - async ({ startIndex }: ListRange) => { - const idSlice = entriesIds?.slice(0, startIndex) - - 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") + if (!feedId || !activeEntryId) return + + batchMarkUnread([feedId, activeEntryId]) + }, [activeEntry, activeEntryId, feedId]) + + 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<{ setUnreadOnly(!unreadOnly)} + onClick={() => setGeneralSetting("unreadOnly", !unreadOnly)} > {unreadOnly ? ( @@ -421,8 +298,7 @@ const ListContent = forwardRef((props, ref) => ( const EmptyList = forwardRef>( (props, ref) => { - const unreadOnly = useAtomValue(unreadOnlyAtom) - + const unreadOnly = useGeneralSettingKey("unreadOnly") return ( { - 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 switch (view) { diff --git a/src/renderer/src/modules/entry-column/readme.md b/src/renderer/src/modules/entry-column/readme.md new file mode 100644 index 0000000000..da9ebd8293 --- /dev/null +++ b/src/renderer/src/modules/entry-column/readme.md @@ -0,0 +1,5 @@ +Mark as read has three logic now. + +- Rendering entry as read. +- Hover entry as read +- Scrolling entry as read \ No newline at end of file diff --git a/src/renderer/src/modules/settings/tabs/general.tsx b/src/renderer/src/modules/settings/tabs/general.tsx index c8cc217d82..a6ff284f3d 100644 --- a/src/renderer/src/modules/settings/tabs/general.tsx +++ b/src/renderer/src/modules/settings/tabs/general.tsx @@ -36,6 +36,16 @@ export const SettingGeneral = () => { /> )} + + + setGeneralSetting("unreadOnly", checked)} + label="Show unread content initially" + /> + + Only show unread content initially when you open the app + { label="Mark as read when scrolling" /> - 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. @@ -57,7 +67,19 @@ export const SettingGeneral = () => { label="Mark as read when hovering" /> - Automatic marking of feed entry as read when the item is hovered. + Automatic marking of feed entries as read when the item is hovered. + + + + setGeneralSetting("renderMarkUnread", checked)} + label="Mark as read when in the viewport" + /> + + 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. diff --git a/src/renderer/src/store/entry/store.ts b/src/renderer/src/store/entry/store.ts index a8306b2378..60d9c98b13 100644 --- a/src/renderer/src/store/entry/store.ts +++ b/src/renderer/src/store/entry/store.ts @@ -231,3 +231,5 @@ export const useEntryStore = createZustandStore( })) export const entryActions = getStoreActions(useEntryStore) + +export const getEntry = (entryId: string) => useEntryStore.getState().flatMapEntries[entryId] diff --git a/src/renderer/src/styles/additional.css b/src/renderer/src/styles/additional.css new file mode 100644 index 0000000000..e08b497de0 --- /dev/null +++ b/src/renderer/src/styles/additional.css @@ -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 + ); +} diff --git a/src/renderer/src/styles/main.css b/src/renderer/src/styles/main.css index e113da6833..13784e591f 100644 --- a/src/renderer/src/styles/main.css +++ b/src/renderer/src/styles/main.css @@ -2,3 +2,4 @@ @import "./font.css"; @import "./base.css"; @import "./colors.css"; +@import "./additional.css"; diff --git a/src/renderer/src/styles/tailwind-extend.css b/src/renderer/src/styles/tailwind-extend.css index b02202c995..d1c22d8155 100644 --- a/src/renderer/src/styles/tailwind-extend.css +++ b/src/renderer/src/styles/tailwind-extend.css @@ -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 - ); - } -}