Skip to content

Commit

Permalink
feat: mark read out of scroll (#27)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
Innei authored May 30, 2024
1 parent 67bab24 commit 9920c46
Show file tree
Hide file tree
Showing 13 changed files with 228 additions and 86 deletions.
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
Expand All @@ -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",
Expand Down
42 changes: 42 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 7 additions & 4 deletions src/renderer/src/components/entry-column/article-item.tsx
Original file line number Diff line number Diff line change
@@ -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">
Expand All @@ -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
Expand Down
191 changes: 140 additions & 51 deletions src/renderer/src/components/entry-column/index.tsx
Original file line number Diff line number Diff line change
@@ -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()

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
let Item: FC<UniversalItemProps>
switch (activeList?.view) {
case 0: {
Item = ArticleItem
Expand All @@ -54,60 +74,129 @@ export function EntryColumn() {
}
}

const List = useMemo(() => forwardRef((props, ref: LegacyRef<HTMLDivElement>) => (
<m.div
key={`${activeList?.level}-${activeList?.id}`}
initial={{ opacity: 0.01, y: 100 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0.01, y: -100 }}
className={cn(
"h-full px-2",
activeList?.view &&
gridMode.has(activeList.view) &&
"grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4",
)}
{...props}
ref={ref}
/>
)), [filterTab, activeList])
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 }),
)
}
}

const Header = useMemo(() => () => (
<div className="mb-5 px-9 flex justify-between items-center w-full">
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">
{entries.data?.pages?.[0].total}
{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>
<TabsTrigger variant="rounded" value="unread">
Unread
</TabsTrigger>
<TabsTrigger variant="rounded" value="all">
All
</TabsTrigger>
</TabsList>
</Tabs>
</div>
), [activeList, entries.data?.pages?.[0].total])
)
}

const ListContent = forwardRef<HTMLDivElement>((props, ref) => {
const activeList = useFeedStore(useShallow((state) => state.activeList))

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>
))}
<m.div
key={`${activeList?.level}-${activeList?.id}`}
initial={{ opacity: 0.01, y: 100 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0.01, y: -100 }}
className={cn(
"h-full px-2",
activeList?.view &&
gridMode.has(activeList.view) &&
"grid grid-cols-2 gap-2 md:grid-cols-3 lg:grid-cols-4",
)}
{...props}
ref={ref}
/>
)
}
})
Loading

0 comments on commit 9920c46

Please sign in to comment.