feat: mark read out of scroll (#27)

* feat: mark read out of scroll

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

* chore: rename

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

---------

Signed-off-by: Innei <i@innei.in>
This commit is contained in:
Innei 2024-05-30 15:02:52 +08:00 committed by GitHub
parent 67bab248f5
commit 9920c46fd6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 230 additions and 88 deletions

View File

@ -59,6 +59,7 @@
"jotai-dark": "^0.3.0",
"jotai-effect": "^1.0.0",
"lethargy": "1.0.9",
"lodash-es": "4.17.21",
"lucide-react": "0.379.0",
"ofetch": "1.3.4",
"react-hook-form": "7.51.5",
@ -74,6 +75,7 @@
"tldts": "6.1.21",
"unified": "11.0.4",
"unist-util-visit": "5.0.0",
"usehooks-ts": "3.1.0",
"vfile": "6.0.1",
"zod": "3.23.8",
"zustand": "4.5.2"
@ -83,6 +85,7 @@
"@electron-toolkit/tsconfig": "^1.0.1",
"@iconify-json/mingcute": "1.1.17",
"@tailwindcss/typography": "0.5.13",
"@types/lodash-es": "4.17.12",
"@types/node": "^20.12.12",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",

View File

@ -116,6 +116,9 @@ importers:
lethargy:
specifier: 1.0.9
version: 1.0.9
lodash-es:
specifier: 4.17.21
version: 4.17.21
lucide-react:
specifier: 0.379.0
version: 0.379.0(react@18.3.1)
@ -161,6 +164,9 @@ importers:
unist-util-visit:
specifier: 5.0.0
version: 5.0.0
usehooks-ts:
specifier: 3.1.0
version: 3.1.0(react@18.3.1)
vfile:
specifier: 6.0.1
version: 6.0.1
@ -183,6 +189,9 @@ importers:
'@tailwindcss/typography':
specifier: 0.5.13
version: 0.5.13(tailwindcss@3.4.3)
'@types/lodash-es':
specifier: 4.17.12
version: 4.17.12
'@types/node':
specifier: ^20.12.12
version: 20.12.12
@ -1843,6 +1852,12 @@ packages:
'@types/keyv@3.1.4':
resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
'@types/lodash-es@4.17.12':
resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==}
'@types/lodash@4.17.4':
resolution: {integrity: sha512-wYCP26ZLxaT3R39kiN2+HcJ4kTd3U1waI/cY7ivWYqFP6pW3ZNpvi6Wd6PHZx7T/t8z0vlkXMg3QYLa7DZ/IJQ==}
'@types/mdast@4.0.3':
resolution: {integrity: sha512-LsjtqsyF+d2/yFOYaN22dHZI1Cpwkrj+g06G8+qtUKlhovPW89YhqSnfKtMbkgmEtYpH2gydRNULd6y8mciAFg==}
@ -3612,9 +3627,15 @@ packages:
resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
lodash.castarray@4.4.0:
resolution: {integrity: sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==}
lodash.debounce@4.0.8:
resolution: {integrity: sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==}
lodash.defaults@4.2.0:
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
@ -4896,6 +4917,12 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
usehooks-ts@3.1.0:
resolution: {integrity: sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw==}
engines: {node: '>=16.15.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18
utf8-byte-length@1.0.4:
resolution: {integrity: sha512-4+wkEYLBbWxqTahEsWrhxepcoVOJ+1z5PGIjPZxRkytcdSUaNjIjBM7Xn8E+pdSuV7SzvWovBFA54FO0JSoqhA==}
@ -6813,6 +6840,12 @@ snapshots:
dependencies:
'@types/node': 20.12.12
'@types/lodash-es@4.17.12':
dependencies:
'@types/lodash': 4.17.4
'@types/lodash@4.17.4': {}
'@types/mdast@4.0.3':
dependencies:
'@types/unist': 3.0.2
@ -9064,8 +9097,12 @@ snapshots:
dependencies:
p-locate: 6.0.0
lodash-es@4.17.21: {}
lodash.castarray@4.4.0: {}
lodash.debounce@4.0.8: {}
lodash.defaults@4.2.0: {}
lodash.difference@4.5.0: {}
@ -10519,6 +10556,11 @@ snapshots:
dependencies:
react: 18.3.1
usehooks-ts@3.1.0(react@18.3.1):
dependencies:
lodash.debounce: 4.0.8
react: 18.3.1
utf8-byte-length@1.0.4: {}
util-deprecate@1.0.2: {}

View File

@ -1,15 +1,16 @@
import { FeedIcon } from "@renderer/components/feed-icon"
import { Image } from "@renderer/components/ui/image"
import dayjs from "@renderer/lib/dayjs"
import type { EntriesResponse } from "@renderer/lib/types"
import { cn } from "@renderer/lib/utils"
export function ArticleItem({ entry }: { entry: EntriesResponse[number] }) {
import type { UniversalItemProps } from "./types"
export function ArticleItem({ entry }: UniversalItemProps) {
return (
<div className="mb-5 flex px-2 py-3">
<FeedIcon feed={entry.feeds} />
<div className="-mt-0.5 line-clamp-5 flex-1 text-sm leading-tight">
<div className="text-[10px] font-bold text-zinc-500 flex gap-1">
<div className="flex gap-1 text-[10px] font-bold text-zinc-500">
<span className="truncate">{entry.feeds.title}</span>
<span>·</span>
<span className="shrink-0">
@ -30,7 +31,9 @@ export function ArticleItem({ entry }: { entry: EntriesResponse[number] }) {
>
{entry.entries.title}
</div>
<div className="text-[13px] text-zinc-500">{entry.entries.description}</div>
<div className="text-[13px] text-zinc-500">
{entry.entries.description}
</div>
</div>
{entry.entries.images?.[0] && (
<Image

View File

@ -1,33 +1,53 @@
import { Tabs, TabsList, TabsTrigger } from "@renderer/components/ui/tabs"
import { buildStorageNS } from "@renderer/lib/ns"
import type { EntryModel } from "@renderer/lib/types"
import { cn } from "@renderer/lib/utils"
import { apiClient } from "@renderer/queries/api-fetch"
import { useEntries } from "@renderer/queries/entries"
import { useFeedStore } from "@renderer/store"
import { m } from "framer-motion"
import type { LegacyRef } from "react"
import { forwardRef, useMemo, useState } from "react"
import { useAtom, useAtomValue } from "jotai"
import { atomWithStorage } from "jotai/utils"
import { debounce } from "lodash-es"
import type { FC } from "react"
import { forwardRef } from "react"
import type { ListRange } from "react-virtuoso"
import { Virtuoso } from "react-virtuoso"
import { useEventCallback } from "usehooks-ts"
import { useShallow } from "zustand/react/shallow"
import { ArticleItem } from "./article-item"
import { EntryItemWrapper } from "./item-wrapper"
import { NotificationItem } from "./notification-item"
import { PictureItem } from "./picture-item"
import { SocialMediaItem } from "./social-media-item"
import type { FilterTab, UniversalItemProps } from "./types"
import { VideoItem } from "./video-item"
const gridMode = new Set([2, 3])
const filterTabAtom = atomWithStorage<FilterTab>(
buildStorageNS("entry-tab"),
"unread",
)
export function EntryColumn() {
const [filterTab, setFilterTab] = useState("unread")
const activeList = useFeedStore((state) => state.activeList)
const entries = useEntries({
level: activeList?.level,
id: activeList?.id,
view: activeList?.view,
...(filterTab === "unread" && { read: false }),
})
const entries = useEntriesByTab()
let Item
const entriesIds = (entries.data?.pages?.flatMap((page) =>
page.data?.map((entry) => entry.entries.id),
) || []) as string[]
const entriesId2Map =
entries.data?.pages?.reduce((acc, page) => {
if (!page.data) return acc
for (const entry of page.data) {
acc[entry.entries.id] = entry
}
return acc
}, {} as Record<string, EntryModel>) ?? {}
let Item: FC<UniversalItemProps>
switch (activeList?.view) {
case 0: {
Item = ArticleItem
@ -54,7 +74,116 @@ export function EntryColumn() {
}
}
const List = useMemo(() => forwardRef((props, ref: LegacyRef<HTMLDivElement>) => (
const handleRangeChange = useEventCallback(
debounce(
async ({ startIndex }: ListRange) => {
const idSlice = entriesIds?.slice(0, startIndex)
if (!idSlice) return
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const requestTasks = [] as Promise<any>[]
for (const id of idSlice) {
const entry = entriesId2Map[id]
if (!entry) continue
const isRead = entry.read
if (!isRead) {
// TODO csrfToken should omit and batch request
requestTasks.push(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
apiClient.reads.$post({ json: { entryId: id } as any }),
)
}
}
await Promise.all(requestTasks)
// TODO optimistic update
if (requestTasks.length > 0) entries.refetch()
},
1000,
{ leading: false },
),
)
return (
<div className="relative flex h-full flex-1 flex-col">
<ListHeader />
<Virtuoso
className="h-0 grow"
components={{
List: ListContent,
}}
rangeChanged={handleRangeChange}
totalCount={entriesIds?.length}
endReached={() => entries.hasNextPage && entries.fetchNextPage()}
data={entries.data?.pages.flatMap((page) => page.data)}
itemContent={(_, entry) => {
if (!entry) return null
return (
<EntryItemWrapper
key={entry.entries.id}
entry={entry}
view={activeList?.view}
>
<Item entry={entry} />
</EntryItemWrapper>
)
}}
/>
</div>
)
}
const useEntriesByTab = () => {
const activeList = useFeedStore(useShallow((state) => state.activeList))
const filterTab = useAtomValue(filterTabAtom)
return useEntries({
level: activeList?.level,
id: activeList?.id,
view: activeList?.view,
...(filterTab === "unread" && { read: false }),
})
}
const ListHeader: FC = () => {
const activeList = useFeedStore(useShallow((state) => state.activeList))
const [filterTab, setFilterTab] = useAtom(filterTabAtom)
const entries = useEntriesByTab()
const total = entries.data?.pages?.reduce(
(acc, page) => acc + (page.data?.length || 0),
0,
)
return (
<div className="mb-5 flex w-full items-center justify-between px-9">
<div>
<div className="text-lg font-bold">{activeList?.name}</div>
<div className="text-xs font-medium text-zinc-400">
{total}
{" "}
Items
</div>
</div>
{/* @ts-expect-error */}
<Tabs value={filterTab} onValueChange={setFilterTab}>
<TabsList variant="rounded">
<TabsTrigger variant="rounded" value="unread">
Unread
</TabsTrigger>
<TabsTrigger variant="rounded" value="all">
All
</TabsTrigger>
</TabsList>
</Tabs>
</div>
)
}
const ListContent = forwardRef<HTMLDivElement>((props, ref) => {
const activeList = useFeedStore(useShallow((state) => state.activeList))
return (
<m.div
key={`${activeList?.level}-${activeList?.id}`}
initial={{ opacity: 0.01, y: 100 }}
@ -69,45 +198,5 @@ export function EntryColumn() {
{...props}
ref={ref}
/>
)), [filterTab, activeList])
const Header = useMemo(() => () => (
<div className="mb-5 px-9 flex justify-between items-center w-full">
<div>
<div className="text-lg font-bold">{activeList?.name}</div>
<div className="text-xs font-medium text-zinc-400">
{entries.data?.pages?.[0].total}
{" "}
Items
</div>
</div>
<Tabs value={filterTab} onValueChange={setFilterTab}>
<TabsList variant="rounded">
<TabsTrigger variant="rounded" value="unread">Unread</TabsTrigger>
<TabsTrigger variant="rounded" value="all">All</TabsTrigger>
</TabsList>
</Tabs>
</div>
), [activeList, entries.data?.pages?.[0].total])
return (
<Virtuoso
components={{
Header,
List,
}}
endReached={() =>
entries.hasNextPage && entries.fetchNextPage()}
data={entries.data?.pages}
itemContent={(_, page) => page?.data?.map((entry) => (
<EntryItemWrapper
key={entry.entries.id}
entry={entry}
view={activeList?.view}
>
<Item entry={entry} />
</EntryItemWrapper>
))}
/>
)
}
})

View File

@ -1,5 +1,4 @@
import { useEntryActions } from "@renderer/hooks/useEntryActions"
import { usePrevious } from "@renderer/hooks/usePrevious"
import { useUpdateEntry } from "@renderer/hooks/useUpdateEntry"
import { showNativeMenu } from "@renderer/lib/native-menu"
import type { EntriesResponse, EntryResponse } from "@renderer/lib/types"
@ -7,8 +6,6 @@ import { cn } from "@renderer/lib/utils"
import { apiFetch } from "@renderer/queries/api-fetch"
import { feedActions, useFeedStore } from "@renderer/store"
import { useMutation } from "@tanstack/react-query"
import { useEffect } from "react"
import { useInView } from "react-intersection-observer"
export function EntryItemWrapper({
entry,
@ -46,18 +43,18 @@ export function EntryItemWrapper({
},
})
const { ref, inView } = useInView({
threshold: 1,
delay: 1000,
})
const prevInView = usePrevious(inView)
useEffect(() => {
if (prevInView && !inView && !entry.read) {
read.mutate()
}
}, [entry.read, inView, read, prevInView])
// const { ref, inView } = useInView({
// threshold: 1,
// delay: 1000,
// })
// const prevInView = usePrevious(inView)
// useEffect(() => {
// if (prevInView && !inView && !entry.read) {
// read.mutate()
// }
// }, [entry.read, inView, read, prevInView])
if (!entry?.entries.url || view === undefined) return children
// if (!entry?.entries.url || view === undefined) return children
return (
<div
@ -67,7 +64,7 @@ export function EntryItemWrapper({
activeEntry === entry.entries.id && "bg-native-active/50",
entry.read && "text-foreground/80",
)}
ref={ref}
// ref={ref}
onClick={(e) => {
e.stopPropagation()
feedActions.setActiveEntry(entry.entries.id)

View File

@ -1,12 +1,9 @@
import { FeedIcon } from "@renderer/components/feed-icon"
import dayjs from "@renderer/lib/dayjs"
import type { EntriesResponse } from "@renderer/lib/types"
export function NotificationItem({
entry,
}: {
entry: EntriesResponse[number]
}) {
import type { UniversalItemProps } from "./types"
export function NotificationItem({ entry }: UniversalItemProps) {
return (
<div className="mb-5 flex px-2 py-3">
<FeedIcon feed={entry.feeds} />

View File

@ -1,9 +1,10 @@
import { FeedIcon } from "@renderer/components/feed-icon"
import { Image } from "@renderer/components/ui/image"
import dayjs from "@renderer/lib/dayjs"
import type { EntriesResponse } from "@renderer/lib/types"
export function PictureItem({ entry }: { entry: EntriesResponse[number] }) {
import type { UniversalItemProps } from "./types"
export function PictureItem({ entry }: UniversalItemProps) {
return (
<div>
<div>

View File

@ -1,9 +1,10 @@
import { FeedIcon } from "@renderer/components/feed-icon"
import { Image } from "@renderer/components/ui/image"
import dayjs from "@renderer/lib/dayjs"
import type { EntriesResponse } from "@renderer/lib/types"
export function SocialMediaItem({ entry }: { entry: EntriesResponse[number] }) {
import type { UniversalItemProps } from "./types"
export function SocialMediaItem({ entry }: UniversalItemProps) {
return (
<div className="mb-5 flex px-2 py-3">
<FeedIcon feed={entry.feeds} />

View File

@ -0,0 +1,5 @@
import type { EntriesResponse } from "@renderer/lib/types"
export interface UniversalItemProps { entry: EntriesResponse[number] }
export type FilterTab = "all" | "unread"

View File

@ -1,9 +1,10 @@
import { FeedIcon } from "@renderer/components/feed-icon"
import { Image } from "@renderer/components/ui/image"
import dayjs from "@renderer/lib/dayjs"
import type { EntriesResponse } from "@renderer/lib/types"
export function VideoItem({ entry }: { entry: EntriesResponse[number] }) {
import type { UniversalItemProps } from "./types"
export function VideoItem({ entry }: UniversalItemProps) {
return (
<div className="flex">
<div className="w-full">

View File

@ -47,15 +47,15 @@ export const useUpdateEntry = ({
queryKey: ["subscriptions"],
})
entriesData.forEach(([key, data]: [QueryKey, unknown]) => {
const chage = changed.read ? -1 : 1
const change = changed.read ? -1 : 1
const assertData = data as SubscriptionsResponse
const finaldata = produce(assertData, (assertData) => {
for (const list of assertData.list) {
for (const item of list.list) {
if (item.feeds.id === feedId) {
assertData.unread += chage
list.unread += chage
item.unread = (item.unread || 0) + chage
assertData.unread += change
list.unread += change
item.unread = (item.unread || 0) + change
}
}
}

View File

@ -0,0 +1,2 @@
const ns = "follow"
export const buildStorageNS = (key: string) => `${ns}:${key}`

View File

@ -38,6 +38,7 @@ export type EntriesResponse = Array<
>[number]
>
export type EntryModel = EntriesResponse[number]
export type DiscoverResponse = Array<
Exclude<
InferResponseType<typeof apiClient.discover.$post>["data"],