feat: subscription store and unread store, unread calcaulation logic (#36)
* feat: persist Signed-off-by: Innei <i@innei.in> * refactor: store Signed-off-by: Innei <i@innei.in> * feat: unread logic Signed-off-by: Innei <i@innei.in> * chore: comment deprecated code Signed-off-by: Innei <i@innei.in> * chore: cleanup Signed-off-by: Innei <i@innei.in> --------- Signed-off-by: Innei <i@innei.in>
This commit is contained in:
parent
d5d9ce6feb
commit
6c73deca0e
|
|
@ -20,6 +20,7 @@ export default defineConfig(
|
|||
"no-console": "warn",
|
||||
"unicorn/consistent-function-scoping": "warn",
|
||||
"unicorn/prefer-module": "off",
|
||||
"@typescript-eslint/no-floating-promises": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@
|
|||
"electron-updater": "^6.1.7",
|
||||
"framer-motion": "11.2.9",
|
||||
"hast-util-to-jsx-runtime": "2.3.0",
|
||||
"idb-keyval": "6.2.1",
|
||||
"immer": "10.1.1",
|
||||
"jotai": "2.8.2",
|
||||
"jotai-dark": "0.3.1",
|
||||
|
|
|
|||
|
|
@ -107,6 +107,9 @@ importers:
|
|||
hast-util-to-jsx-runtime:
|
||||
specifier: 2.3.0
|
||||
version: 2.3.0
|
||||
idb-keyval:
|
||||
specifier: 6.2.1
|
||||
version: 6.2.1
|
||||
immer:
|
||||
specifier: 10.1.1
|
||||
version: 10.1.1
|
||||
|
|
@ -3393,6 +3396,9 @@ packages:
|
|||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
idb-keyval@6.2.1:
|
||||
resolution: {integrity: sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==}
|
||||
|
||||
ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
|
||||
|
|
@ -9052,6 +9058,8 @@ snapshots:
|
|||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
idb-keyval@6.2.1: {}
|
||||
|
||||
ieee754@1.2.1: {}
|
||||
|
||||
ignore@5.3.1: {}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
} from "@renderer/components/ui/form"
|
||||
import { Image } from "@renderer/components/ui/image"
|
||||
import { Input } from "@renderer/components/ui/input"
|
||||
import type { DiscoverResponse } from "@renderer/lib/types"
|
||||
import type { DiscoverResponse } from "@renderer/models"
|
||||
import { apiFetch } from "@renderer/queries/api-fetch"
|
||||
import { DEEPLINK_SCHEME } from "@shared/constants"
|
||||
import { openElectronWindow } from "@shared/electron"
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ import {
|
|||
FormMessage,
|
||||
} from "@renderer/components/ui/form"
|
||||
import { Input } from "@renderer/components/ui/input"
|
||||
import type { FeedResponse } from "@renderer/lib/types"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
import type { FeedResponse } from "@renderer/models"
|
||||
import { Queries } from "@renderer/queries"
|
||||
import { apiFetch } from "@renderer/queries/api-fetch"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import {
|
|||
import { views } from "@renderer/lib/constants"
|
||||
import { FeedViewType } from "@renderer/lib/enum"
|
||||
import { buildStorageNS } from "@renderer/lib/ns"
|
||||
import type { EntryModel } from "@renderer/lib/types"
|
||||
import { cn, getEntriesParams } from "@renderer/lib/utils"
|
||||
import type { EntryModel } from "@renderer/models"
|
||||
import { apiClient } from "@renderer/queries/api-fetch"
|
||||
import { useEntries } from "@renderer/queries/entries"
|
||||
import { feedActions, useFeedStore } from "@renderer/store"
|
||||
|
|
@ -88,7 +88,7 @@ export function EntryColumn() {
|
|||
|
||||
if (!idSlice) return
|
||||
|
||||
const batchLikeIds = [] as string[]
|
||||
const batchLikeIds = [] as [string, string][]
|
||||
const entriesId2Map = entryActions.getFlattenMapEntries()
|
||||
for (const id of idSlice) {
|
||||
const entry = entriesId2Map[id]
|
||||
|
|
@ -96,15 +96,16 @@ export function EntryColumn() {
|
|||
if (!entry) continue
|
||||
const isRead = entry.read
|
||||
if (!isRead) {
|
||||
batchLikeIds.push(id)
|
||||
batchLikeIds.push([entry.feeds.id, id])
|
||||
}
|
||||
}
|
||||
|
||||
if (batchLikeIds.length > 0) {
|
||||
await apiClient.reads.$post({ json: { entryIds: batchLikeIds } })
|
||||
const entryIds = batchLikeIds.map(([, id]) => id)
|
||||
await apiClient.reads.$post({ json: { entryIds } })
|
||||
|
||||
for (const id of batchLikeIds) {
|
||||
entryActions.optimisticUpdate(id, { read: true })
|
||||
for (const [feedId, id] of batchLikeIds) {
|
||||
entryActions.markRead(feedId, id, true)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -147,16 +148,14 @@ export function EntryColumn() {
|
|||
onClick={() => setActiveEntry?.(null)}
|
||||
>
|
||||
<ListHeader />
|
||||
{activeList?.view && views[activeList.view].gridMode ?
|
||||
(
|
||||
<VirtuosoGrid
|
||||
listClassName="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4 px-4"
|
||||
{...virtuosoOptions}
|
||||
/>
|
||||
) :
|
||||
(
|
||||
<Virtuoso {...virtuosoOptions} />
|
||||
)}
|
||||
{activeList?.view && views[activeList.view].gridMode ? (
|
||||
<VirtuosoGrid
|
||||
listClassName="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4 px-4"
|
||||
{...virtuosoOptions}
|
||||
/>
|
||||
) : (
|
||||
<Virtuoso {...virtuosoOptions} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -197,9 +196,9 @@ const ListHeader: FC = () => {
|
|||
return (
|
||||
<div className="mb-5 flex w-full items-center justify-between pl-9 pr-2">
|
||||
<div className="mt-4">
|
||||
<div className="text-lg font-bold">{activeList?.name}</div>
|
||||
<div className="text-lg font-bold leading-none">{activeList?.name}</div>
|
||||
<div className="text-xs font-medium text-zinc-400">
|
||||
{entries.data?.pages?.[0].total}
|
||||
{entries.data?.pages?.[0].total || 0}
|
||||
{" "}
|
||||
{unreadOnly ? "Unread" : ""}
|
||||
{" "}
|
||||
|
|
@ -221,13 +220,11 @@ const ListHeader: FC = () => {
|
|||
onClick={() => setUnreadOnly(!unreadOnly)}
|
||||
active={unreadOnly}
|
||||
>
|
||||
{unreadOnly ?
|
||||
(
|
||||
<i className="i-mingcute-round-fill" />
|
||||
) :
|
||||
(
|
||||
<i className="i-mingcute-round-line" />
|
||||
)}
|
||||
{unreadOnly ? (
|
||||
<i className="i-mingcute-round-fill" />
|
||||
) : (
|
||||
<i className="i-mingcute-round-line" />
|
||||
)}
|
||||
</HeaderActionButton>
|
||||
<Popover open={markPopoverOpen} onOpenChange={setMarkPopoverOpen}>
|
||||
<PopoverTrigger>
|
||||
|
|
|
|||
|
|
@ -34,9 +34,7 @@ export function EntryItemWrapper({
|
|||
},
|
||||
}),
|
||||
onMutate: () => {
|
||||
entryActions.optimisticUpdate(entry.entries.id, {
|
||||
read: true,
|
||||
})
|
||||
entryActions.markRead(entry.feeds.id, entry.entries.id, true)
|
||||
},
|
||||
// TODO fallback
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { EntryModel, FeedModel } from "@renderer/lib/types"
|
||||
import type { EntryModel, FeedModel } from "@renderer/models"
|
||||
|
||||
export type UniversalItemProps = {
|
||||
entryId: string
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useBizQuery } from "@renderer/hooks/useBizQuery"
|
||||
import { parseHtml } from "@renderer/lib/parse-html"
|
||||
import type { ActiveEntry } from "@renderer/lib/types"
|
||||
import type { ActiveEntry } from "@renderer/models"
|
||||
import { Queries } from "@renderer/queries"
|
||||
import { useFeedStore } from "@renderer/store"
|
||||
import { m } from "framer-motion"
|
||||
|
|
@ -9,7 +9,9 @@ import { useEffect, useState } from "react"
|
|||
import { EntryShare } from "./share"
|
||||
|
||||
export function EntryContent({ entryId }: { entryId: ActiveEntry }) {
|
||||
const entry = useBizQuery(Queries.entries.byId(entryId))
|
||||
const entry = useBizQuery(Queries.entries.byId(entryId), {
|
||||
enabled: !!entryId,
|
||||
})
|
||||
const activeList = useFeedStore((state) => state.activeList)
|
||||
|
||||
const [content, setContent] = useState<JSX.Element>()
|
||||
|
|
|
|||
|
|
@ -7,10 +7,14 @@ import { client } from "@renderer/lib/client"
|
|||
import { levels } from "@renderer/lib/constants"
|
||||
import { showNativeMenu } from "@renderer/lib/native-menu"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
import type { FeedListModel } from "@renderer/models"
|
||||
import { Queries } from "@renderer/queries"
|
||||
import { apiFetch } from "@renderer/queries/api-fetch"
|
||||
import type { Response as SubscriptionsResponse } from "@renderer/queries/subscriptions"
|
||||
import { feedActions, useFeedActiveList } from "@renderer/store"
|
||||
import {
|
||||
feedActions,
|
||||
useFeedActiveList,
|
||||
useUnreadStore,
|
||||
} from "@renderer/store"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { AnimatePresence, m } from "framer-motion"
|
||||
import { useEffect, useState } from "react"
|
||||
|
|
@ -25,7 +29,7 @@ export function FeedCategory({
|
|||
view,
|
||||
expansion,
|
||||
}: {
|
||||
data: SubscriptionsResponse["list"][number]
|
||||
data: FeedListModel["list"][number]
|
||||
view?: number
|
||||
expansion: boolean
|
||||
}) {
|
||||
|
|
@ -56,15 +60,21 @@ export function FeedCategory({
|
|||
}, [expansion])
|
||||
|
||||
const setCategoryActive = () => {
|
||||
view !== undefined &&
|
||||
setActiveList?.({
|
||||
level: levels.folder,
|
||||
id: data.list.map((feed) => feed.feedId).join(","),
|
||||
name: data.name,
|
||||
view,
|
||||
})
|
||||
if (view !== undefined) {
|
||||
setActiveList({
|
||||
level: levels.folder,
|
||||
id: data.list.map((feed) => feed.feedId).join(","),
|
||||
name: data.name,
|
||||
view,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const unread = useUnreadStore((state) =>
|
||||
data.list.reduce((acc, cur) => state.data[cur.feedId] + acc, 0),
|
||||
)
|
||||
|
||||
const sortByUnreadFeedList = useUnreadStore((state) => data.list.sort((a, b) => (state.data[b.feedId] || 0) - (state.data[a.feedId] || 0)))
|
||||
return (
|
||||
<Collapsible
|
||||
open={open}
|
||||
|
|
@ -95,16 +105,16 @@ export function FeedCategory({
|
|||
{
|
||||
type: "text",
|
||||
label: "Delete Category",
|
||||
|
||||
click: async () => {
|
||||
if (
|
||||
await client?.showConfirmDialog({
|
||||
title: `Delete category ${data.name}?`,
|
||||
message: `This operation will delete your category, but the feeds it contains will be retained and grouped by website.`,
|
||||
options: {
|
||||
buttons: ["Delete", "Cancel"],
|
||||
},
|
||||
})
|
||||
) {
|
||||
const result = await client?.showConfirmDialog({
|
||||
title: `Delete category ${data.name}?`,
|
||||
message: `This operation will delete your category, but the feeds it contains will be retained and grouped by website.`,
|
||||
options: {
|
||||
buttons: ["Delete", "Cancel"],
|
||||
},
|
||||
})
|
||||
if (result) {
|
||||
deleteMutation.mutate()
|
||||
}
|
||||
},
|
||||
|
|
@ -126,10 +136,10 @@ export function FeedCategory({
|
|||
<span className="truncate">{data.name}</span>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
{setActiveList && <span className="truncate">{data.name}</span>}
|
||||
{!!setActiveList && <span className="truncate">{data.name}</span>}
|
||||
</div>
|
||||
{!!data.unread && (
|
||||
<div className="ml-2 text-xs text-zinc-500">{data.unread}</div>
|
||||
{!!unread && (
|
||||
<div className="ml-2 text-xs text-zinc-500">{unread}</div>
|
||||
)}
|
||||
<CategoryRenameDialog
|
||||
feedIdList={feedIdList}
|
||||
|
|
@ -157,7 +167,7 @@ export function FeedCategory({
|
|||
opacity: 0.01,
|
||||
}}
|
||||
>
|
||||
{data.list.map((feed) => (
|
||||
{sortByUnreadFeedList.map((feed) => (
|
||||
<FeedItem
|
||||
key={feed.feedId}
|
||||
feed={feed}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export function FeedColumn() {
|
|||
<div
|
||||
className="flex h-full flex-col gap-3"
|
||||
onClick={() =>
|
||||
setActiveList?.({
|
||||
setActiveList({
|
||||
level: levels.view,
|
||||
id: active,
|
||||
name: views[active].name,
|
||||
|
|
@ -72,7 +72,7 @@ export function FeedColumn() {
|
|||
className="flex items-center gap-1 text-xl font-bold"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setActiveList?.({
|
||||
setActiveList({
|
||||
level: levels.view,
|
||||
id: active,
|
||||
name: views[active].name,
|
||||
|
|
|
|||
|
|
@ -11,11 +11,15 @@ import { useToast } from "@renderer/components/ui/use-toast"
|
|||
import { levels } from "@renderer/lib/constants"
|
||||
import dayjs from "@renderer/lib/dayjs"
|
||||
import { showNativeMenu } from "@renderer/lib/native-menu"
|
||||
import type { SubscriptionResponse } from "@renderer/lib/types"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
import type { SubscriptionResponse } from "@renderer/models"
|
||||
import { Queries } from "@renderer/queries"
|
||||
import { apiFetch } from "@renderer/queries/api-fetch"
|
||||
import { feedActions, useFeedActiveList } from "@renderer/store"
|
||||
import {
|
||||
feedActions,
|
||||
useFeedActiveList,
|
||||
useUnreadStore,
|
||||
} from "@renderer/store"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
|
||||
export function FeedItem({
|
||||
|
|
@ -31,8 +35,9 @@ export function FeedItem({
|
|||
const { setActiveList } = feedActions
|
||||
|
||||
const setFeedActive = (feed: SubscriptionResponse[number]) => {
|
||||
view !== undefined &&
|
||||
setActiveList?.({
|
||||
if (view === undefined) return
|
||||
|
||||
setActiveList({
|
||||
level: levels.feed,
|
||||
id: feed.feedId,
|
||||
name: feed.feeds.title || "",
|
||||
|
|
@ -88,6 +93,7 @@ export function FeedItem({
|
|||
},
|
||||
})
|
||||
|
||||
const feedUnread = useUnreadStore((state) => state.data[feed.feedId] || 0)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
|
@ -128,7 +134,9 @@ export function FeedItem({
|
|||
label: "Open Feed in Browser",
|
||||
click: () =>
|
||||
window.open(
|
||||
`${import.meta.env.VITE_WEB_URL}/feed/${feed.feedId}?view=${view}`,
|
||||
`${import.meta.env.VITE_WEB_URL}/feed/${
|
||||
feed.feedId
|
||||
}?view=${view}`,
|
||||
"_blank",
|
||||
),
|
||||
},
|
||||
|
|
@ -136,8 +144,7 @@ export function FeedItem({
|
|||
type: "text",
|
||||
label: "Open Site in Browser",
|
||||
click: () =>
|
||||
feed.feeds.siteUrl &&
|
||||
window.open(feed.feeds.siteUrl, "_blank"),
|
||||
feed.feeds.siteUrl && window.open(feed.feeds.siteUrl, "_blank"),
|
||||
},
|
||||
],
|
||||
e,
|
||||
|
|
@ -188,8 +195,8 @@ export function FeedItem({
|
|||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
{!!feed.unread && (
|
||||
<div className="ml-2 text-xs text-zinc-500">{feed.unread}</div>
|
||||
{!!feedUnread && (
|
||||
<div className="ml-2 text-xs text-zinc-500">{feedUnread}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,13 +1,83 @@
|
|||
import { useBizQuery } from "@renderer/hooks/useBizQuery"
|
||||
import { levels, views } from "@renderer/lib/constants"
|
||||
import type { FeedViewType } from "@renderer/lib/enum"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
import type { FeedListModel, SubscriptionResponse } from "@renderer/models"
|
||||
import { Queries } from "@renderer/queries"
|
||||
import { feedActions } from "@renderer/store"
|
||||
import { useState } from "react"
|
||||
import { feedActions, useUnreadStore } from "@renderer/store"
|
||||
import { useMemo, useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { parse } from "tldts"
|
||||
|
||||
import { FeedCategory } from "./category"
|
||||
|
||||
const useData = (view?: FeedViewType) => {
|
||||
const subscriptions = useBizQuery(Queries.subscription.byView(view))
|
||||
|
||||
// TODO Refactor this into category store
|
||||
return useMemo(() => {
|
||||
if (!subscriptions.data) return null
|
||||
const categories = {
|
||||
list: {},
|
||||
} as {
|
||||
list: Record<
|
||||
string,
|
||||
{
|
||||
list: SubscriptionResponse
|
||||
}
|
||||
>
|
||||
}
|
||||
const domains: Record<string, number> = {}
|
||||
const data = structuredClone(subscriptions.data)
|
||||
|
||||
if (subscriptions) {
|
||||
for (const subscription of data.subscriptions) {
|
||||
if (!subscription.category && subscription.feeds.siteUrl) {
|
||||
const { domain } = parse(subscription.feeds.siteUrl)
|
||||
if (domain) {
|
||||
if (!domains[domain]) {
|
||||
domains[domain] = 0
|
||||
}
|
||||
domains[domain]++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (subscriptions) {
|
||||
for (const subscription of data.subscriptions) {
|
||||
if (!subscription.category) {
|
||||
if (subscription.feeds.siteUrl) {
|
||||
const { domain } = parse(subscription.feeds.siteUrl)
|
||||
if (domain && domains[domain] > 1) {
|
||||
subscription.category =
|
||||
domain.slice(0, 1).toUpperCase() + domain.slice(1)
|
||||
}
|
||||
}
|
||||
if (!subscription.category) {
|
||||
subscription.category = ""
|
||||
}
|
||||
}
|
||||
if (!categories.list[subscription.category]) {
|
||||
categories.list[subscription.category] = {
|
||||
list: [],
|
||||
}
|
||||
}
|
||||
|
||||
const unread = data.unreads[subscription.feedId] || 0
|
||||
subscription.unread = unread
|
||||
categories.list[subscription.category].list.push(subscription)
|
||||
}
|
||||
}
|
||||
const list = Object.entries(categories.list).map(([name, list]) => ({
|
||||
name,
|
||||
list: list.list,
|
||||
}))
|
||||
|
||||
return {
|
||||
list,
|
||||
} as FeedListModel
|
||||
}, [subscriptions])
|
||||
}
|
||||
export function FeedList({
|
||||
className,
|
||||
view,
|
||||
|
|
@ -17,10 +87,29 @@ export function FeedList({
|
|||
view?: number
|
||||
hideTitle?: boolean
|
||||
}) {
|
||||
const subscriptions = useBizQuery(Queries.subscription.byView(view))
|
||||
const [expansion, setExpansion] = useState(false)
|
||||
const data = useData(view)
|
||||
|
||||
const totalUnread = useUnreadStore((state) => {
|
||||
let unread = 0
|
||||
data?.list.forEach((a) => {
|
||||
a.list.forEach((b) => {
|
||||
unread += state.data[b.feedId] || 0
|
||||
})
|
||||
})
|
||||
return unread
|
||||
})
|
||||
|
||||
const { setActiveList } = feedActions
|
||||
|
||||
const sortedByUnread = useUnreadStore((state) =>
|
||||
data?.list?.sort(
|
||||
(a, b) =>
|
||||
b.list.reduce((acc, cur) => (state.data[cur.feedId] || 0) + acc, 0) -
|
||||
a.list.reduce((acc, cur) => (state.data[cur.feedId] || 0) + acc, 0),
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{!hideTitle && (
|
||||
|
|
@ -31,52 +120,54 @@ export function FeedList({
|
|||
className="font-bold"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
view !== undefined &&
|
||||
setActiveList?.({
|
||||
level: levels.view,
|
||||
id: view,
|
||||
name: views[view].name,
|
||||
view,
|
||||
})
|
||||
if (view !== undefined) {
|
||||
setActiveList({
|
||||
level: levels.view,
|
||||
id: view,
|
||||
name: views[view].name,
|
||||
view,
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{view !== undefined && views[view].name}
|
||||
</div>
|
||||
<div className="ml-2 flex items-center gap-3 text-sm text-theme-vibrancyFg">
|
||||
{expansion ?
|
||||
(
|
||||
<i
|
||||
className="i-mingcute-list-collapse-fill"
|
||||
onClick={() => setExpansion(false)}
|
||||
/>
|
||||
) :
|
||||
(
|
||||
<i
|
||||
className="i-mingcute-list-expansion-fill"
|
||||
onClick={() => setExpansion(true)}
|
||||
/>
|
||||
)}
|
||||
<span>{subscriptions.data?.unread}</span>
|
||||
{expansion ? (
|
||||
<i
|
||||
className="i-mingcute-list-collapse-fill"
|
||||
onClick={() => setExpansion(false)}
|
||||
/>
|
||||
) : (
|
||||
<i
|
||||
className="i-mingcute-list-expansion-fill"
|
||||
onClick={() => setExpansion(true)}
|
||||
/>
|
||||
)}
|
||||
<span>{totalUnread}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{subscriptions.data?.list?.length ?
|
||||
subscriptions.data?.list.map((category) => (
|
||||
{data?.list?.length ? (
|
||||
sortedByUnread?.map((category) => (
|
||||
<FeedCategory
|
||||
key={category.name}
|
||||
data={category}
|
||||
view={view}
|
||||
expansion={expansion}
|
||||
/>
|
||||
)) :
|
||||
(
|
||||
<div className="flex h-full flex-1 items-center text-zinc-500">
|
||||
<Link to="/discover" className="-mt-36 flex h-full flex-1 flex-col items-center justify-center gap-2">
|
||||
<i className="i-mingcute-add-line text-3xl " />
|
||||
Add some feeds
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
))
|
||||
) : (
|
||||
<div className="flex h-full flex-1 items-center text-zinc-500">
|
||||
<Link
|
||||
to="/discover"
|
||||
className="-mt-36 flex h-full flex-1 flex-col items-center justify-center gap-2"
|
||||
>
|
||||
<i className="i-mingcute-add-line text-3xl " />
|
||||
Add some feeds
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { SiteIcon } from "@renderer/components/site-icon"
|
||||
import { Image } from "@renderer/components/ui/image"
|
||||
import type { FeedModel } from "@renderer/lib/types"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
import type { FeedModel } from "@renderer/models"
|
||||
|
||||
export function FeedIcon({
|
||||
feed,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { FeedIcon } from "@renderer/components/feed-icon"
|
||||
import type { FeedModel } from "@renderer/lib/types"
|
||||
import { cn } from "@renderer/lib/utils"
|
||||
import type { FeedModel } from "@renderer/models"
|
||||
|
||||
export function FollowSummary({
|
||||
feed,
|
||||
|
|
|
|||
|
|
@ -60,29 +60,31 @@ export const AutoComplete = React.forwardRef<HTMLInputElement, InputProps>(
|
|||
}}
|
||||
ref={ref}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandGroup
|
||||
className={cn(
|
||||
"absolute top-full z-10 mt-1 hidden w-full rounded-lg border bg-background [&[hidden]]:!hidden",
|
||||
open && "block",
|
||||
)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option}
|
||||
value={option}
|
||||
onSelect={() => handleSelectOption(option)}
|
||||
className="text-zinc-800"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
{options.length > 0 && (
|
||||
<CommandList>
|
||||
<CommandGroup
|
||||
className={cn(
|
||||
"absolute top-full z-10 mt-1 hidden w-full rounded-lg border bg-background [&[hidden]]:!hidden",
|
||||
open && "block",
|
||||
)}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option}
|
||||
value={option}
|
||||
onSelect={() => handleSelectOption(option)}
|
||||
className="text-zinc-800"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
>
|
||||
{option}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
)}
|
||||
</Command>
|
||||
)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -36,10 +36,7 @@ declare const routes: hono_hono_base.HonoBase<hono.Env, {
|
|||
};
|
||||
output: {
|
||||
code: 0;
|
||||
data: {
|
||||
feedId: string;
|
||||
unread: number;
|
||||
}[];
|
||||
data: Record<string, number>;
|
||||
};
|
||||
outputFormat: "json";
|
||||
status: 200;
|
||||
|
|
@ -451,7 +448,7 @@ declare const routes: hono_hono_base.HonoBase<hono.Env, {
|
|||
};
|
||||
output: {
|
||||
code: 0;
|
||||
data?: {
|
||||
data: {
|
||||
title: string | null;
|
||||
userId: string;
|
||||
feeds: {
|
||||
|
|
@ -473,7 +470,7 @@ declare const routes: hono_hono_base.HonoBase<hono.Env, {
|
|||
view: number;
|
||||
category: string | null;
|
||||
isPrivate: boolean | null;
|
||||
}[] | undefined;
|
||||
}[];
|
||||
};
|
||||
outputFormat: "json";
|
||||
status: 200;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useToast } from "@renderer/components/ui/use-toast"
|
||||
import { client } from "@renderer/lib/client"
|
||||
import type { EntriesResponse } from "@renderer/lib/types"
|
||||
import type { EntriesResponse } from "@renderer/models"
|
||||
import { apiClient, apiFetch } from "@renderer/queries/api-fetch"
|
||||
import { entryActions } from "@renderer/store/entry"
|
||||
import { useMutation, useQuery } from "@tanstack/react-query"
|
||||
|
|
@ -82,9 +82,8 @@ export const useEntryActions = ({
|
|||
|
||||
onMutate: () => {
|
||||
if (!entry) return
|
||||
entryActions.optimisticUpdate(entry.entries.id, {
|
||||
read: true,
|
||||
})
|
||||
|
||||
entryActions.markRead(entry.feeds.id, entry.entries.id, true)
|
||||
},
|
||||
})
|
||||
const unread = useMutation({
|
||||
|
|
@ -97,9 +96,8 @@ export const useEntryActions = ({
|
|||
}),
|
||||
onMutate: () => {
|
||||
if (!entry) return
|
||||
entryActions.optimisticUpdate(entry.entries.id, {
|
||||
read: false,
|
||||
})
|
||||
|
||||
entryActions.markRead(entry.feeds.id, entry.entries.id, false)
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
import type { EntriesResponse, ListResponse } from "@renderer/lib/types"
|
||||
import { Queries } from "@renderer/queries"
|
||||
import type { Response as SubscriptionsResponse } from "@renderer/queries/subscriptions"
|
||||
import type { InfiniteData, QueryKey } from "@tanstack/react-query"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { produce } from "immer"
|
||||
|
||||
/**
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const useUpdateEntry = ({
|
||||
entryId,
|
||||
feedId,
|
||||
}: {
|
||||
entryId?: string
|
||||
feedId?: string
|
||||
}) => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const updateEntry = (changed: Partial<EntriesResponse[number]>) => {
|
||||
const query = Queries.entries.byId(entryId)
|
||||
|
||||
query.setData((draft) => {
|
||||
if (!draft) return
|
||||
Object.assign(draft, changed)
|
||||
})
|
||||
|
||||
const entriesData = queryClient.getQueriesData({
|
||||
queryKey: ["entries"],
|
||||
})
|
||||
entriesData.forEach(([key, data]: [QueryKey, unknown]) => {
|
||||
const assertData = data as InfiniteData<ListResponse<EntriesResponse>>
|
||||
const finaldata = produce(assertData, (assertData) => {
|
||||
const list = assertData?.pages?.[0]?.data
|
||||
if (list) {
|
||||
for (const item of list) {
|
||||
if (item.entries.id === entryId) {
|
||||
for (const [key, value] of Object.entries(changed)) {
|
||||
item[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
queryClient.setQueryData<typeof assertData>(key, finaldata)
|
||||
})
|
||||
|
||||
if (changed.read !== undefined) {
|
||||
const entriesData = queryClient.getQueriesData({
|
||||
queryKey: ["subscriptions"],
|
||||
})
|
||||
entriesData.forEach(([key, data]: [QueryKey, unknown]) => {
|
||||
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 += change
|
||||
list.unread += change
|
||||
item.unread = (item.unread || 0) + change
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
queryClient.setQueryData<typeof assertData>(key, finaldata)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return updateEntry
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ const queryClient = new QueryClient({
|
|||
queries: {
|
||||
gcTime: Infinity,
|
||||
refetchInterval: 1000 * 60 * 10,
|
||||
throwOnError: import.meta.env.DEV,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
export * from "./types"
|
||||
|
|
@ -65,3 +65,12 @@ export type DataResponse<T> = {
|
|||
}
|
||||
|
||||
export type ActiveEntry = string | null
|
||||
|
||||
export type SubscriptionModel = SubscriptionResponse[number]
|
||||
|
||||
export type FeedListModel = {
|
||||
list: {
|
||||
list: SubscriptionResponse
|
||||
name: string
|
||||
}[]
|
||||
}
|
||||
|
|
@ -1,96 +1,21 @@
|
|||
import { defineQuery } from "@renderer/lib/defineQuery"
|
||||
import type { SubscriptionResponse } from "@renderer/lib/types"
|
||||
import type { FeedViewType } from "@renderer/lib/enum"
|
||||
import { apiClient } from "@renderer/queries/api-fetch"
|
||||
import { parse } from "tldts"
|
||||
|
||||
export type Response = {
|
||||
list: {
|
||||
list: SubscriptionResponse
|
||||
unread: number
|
||||
name: string
|
||||
}[]
|
||||
unread: number
|
||||
}
|
||||
import { subscriptionActions, unreadActions } from "@renderer/store"
|
||||
|
||||
export const subscription = {
|
||||
byView: (view?: number) =>
|
||||
byView: (view?: FeedViewType) =>
|
||||
defineQuery(
|
||||
["subscriptions", view],
|
||||
async () => {
|
||||
const res = await (
|
||||
await apiClient.subscriptions.$get({ query: { view: String(view) } })
|
||||
).json()
|
||||
|
||||
const unreads = await (
|
||||
await apiClient.reads.$get({ query: { view: String(view) } })
|
||||
).json()
|
||||
|
||||
const subscriptions = res.data as SubscriptionResponse
|
||||
const categories = {
|
||||
list: {},
|
||||
unread: 0,
|
||||
} as {
|
||||
list: Record<
|
||||
string,
|
||||
{
|
||||
list: SubscriptionResponse
|
||||
unread: number
|
||||
}
|
||||
>
|
||||
unread: number
|
||||
}
|
||||
const domains: Record<string, number> = {}
|
||||
if (subscriptions) {
|
||||
for (const subscription of subscriptions) {
|
||||
if (!subscription.category && subscription.feeds.siteUrl) {
|
||||
const { domain } = parse(subscription.feeds.siteUrl)
|
||||
if (domain) {
|
||||
if (!domains[domain]) {
|
||||
domains[domain] = 0
|
||||
}
|
||||
domains[domain]++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (subscriptions) {
|
||||
for (const subscription of subscriptions) {
|
||||
if (!subscription.category) {
|
||||
if (subscription.feeds.siteUrl) {
|
||||
const { domain } = parse(subscription.feeds.siteUrl)
|
||||
if (domain && domains[domain] > 1) {
|
||||
subscription.category =
|
||||
domain.slice(0, 1).toUpperCase() + domain.slice(1)
|
||||
}
|
||||
}
|
||||
if (!subscription.category) {
|
||||
subscription.category = ""
|
||||
}
|
||||
}
|
||||
if (!categories.list[subscription.category]) {
|
||||
categories.list[subscription.category] = {
|
||||
list: [],
|
||||
unread: 0,
|
||||
}
|
||||
}
|
||||
const unread = unreads.data.find((x) => x.feedId === subscription.feedId)?.unread || 0
|
||||
subscription.unread = unread
|
||||
categories.list[subscription.category].list.push(subscription)
|
||||
categories.list[subscription.category].unread += unread
|
||||
categories.unread += unread
|
||||
}
|
||||
}
|
||||
const list = Object.entries(categories.list)
|
||||
.map(([name, list]) => ({
|
||||
name,
|
||||
list: list.list.sort((a, b) => (b.unread || 0) - (a.unread || 0)),
|
||||
unread: list.unread,
|
||||
}))
|
||||
.sort((a, b) => b.unread - a.unread)
|
||||
const [subscriptions, unreads] = await Promise.all([
|
||||
subscriptionActions.fetchByView(view),
|
||||
unreadActions.fetchUnreadByView(view),
|
||||
])
|
||||
return {
|
||||
list,
|
||||
unread: categories.unread,
|
||||
} as Response
|
||||
subscriptions,
|
||||
unreads,
|
||||
}
|
||||
},
|
||||
{
|
||||
rootKey: ["subscriptions"],
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
import type { EntryModel } from "@renderer/lib/types"
|
||||
import { getEntriesParams } from "@renderer/lib/utils"
|
||||
import type { EntryModel } from "@renderer/models"
|
||||
import { apiClient } from "@renderer/queries/api-fetch"
|
||||
import type { InferResponseType } from "hono/client"
|
||||
import { produce } from "immer"
|
||||
import { omit } from "lodash-es"
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
|
||||
import { unreadActions } from "./unread"
|
||||
import { zustandStorage } from "./utils/helper"
|
||||
|
||||
type EntriesIdTable = Record<string, Record<string, EntryModel>>
|
||||
|
||||
|
|
@ -26,14 +31,15 @@ interface EntryActions {
|
|||
optimisticUpdate: (entryId: string, changed: Partial<EntryModel>) => void
|
||||
optimisticUpdateAll: (changed: Partial<EntryModel>) => void
|
||||
getFlattenMapEntries: () => Record<string, EntryModel>
|
||||
markRead: (feedId: string, entryId: string, read: boolean) => void
|
||||
}
|
||||
|
||||
export const useEntryStore = create<EntryState & { actions: EntryActions }>(
|
||||
(set, get) => ({
|
||||
entries: {},
|
||||
flatMapEntries: {},
|
||||
export const useEntryStore = create(
|
||||
persist<EntryState & EntryActions>(
|
||||
(set, get) => ({
|
||||
entries: {},
|
||||
flatMapEntries: {},
|
||||
|
||||
actions: {
|
||||
fetchEntries: async ({
|
||||
level,
|
||||
id,
|
||||
|
|
@ -65,7 +71,7 @@ export const useEntryStore = create<EntryState & { actions: EntryActions }>(
|
|||
|
||||
if (data.data) {
|
||||
data.data.forEach((entry: EntryModel) => {
|
||||
get().actions.upsert(entry.feeds.id, entry)
|
||||
get().upsert(entry.feeds.id, entry)
|
||||
})
|
||||
}
|
||||
return data
|
||||
|
|
@ -107,11 +113,27 @@ export const useEntryStore = create<EntryState & { actions: EntryActions }>(
|
|||
}),
|
||||
)
|
||||
},
|
||||
|
||||
markRead: (feedId: string, entryId: string, read: boolean) => {
|
||||
unreadActions.incrementByFeedId(feedId, read ? -1 : 1)
|
||||
entryActions.optimisticUpdate(entryId, {
|
||||
read,
|
||||
})
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "entry",
|
||||
storage: zustandStorage,
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
export const entryActions = useEntryStore.getState().actions
|
||||
export const entryActions = {
|
||||
...(omit(useEntryStore.getState(), [
|
||||
"entries",
|
||||
"flatMapEntries",
|
||||
]) as EntryActions),
|
||||
}
|
||||
|
||||
export const useEntriesByFeedId = (feedId: string) =>
|
||||
useEntryStore((state) => state.entries[feedId])
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { ActiveEntry, ActiveList } from "@renderer/lib/types"
|
||||
import type { ActiveEntry, ActiveList } from "@renderer/models"
|
||||
import { create } from "zustand"
|
||||
|
||||
interface FeedStoreActions {
|
||||
setActiveList: ((value: ActiveList) => void) | undefined
|
||||
setActiveList: (value: ActiveList) => void
|
||||
setActiveEntry: (value: ActiveEntry) => void
|
||||
}
|
||||
interface FeedStoreState {
|
||||
|
|
@ -31,4 +31,5 @@ export const useFeedStore = create<FeedStore>((set) => ({
|
|||
export const feedActions = useFeedStore.getState().actions
|
||||
|
||||
/** Hooks */
|
||||
export const useFeedActiveList = () => useFeedStore((state) => state.activeList)
|
||||
export const useFeedActiveList = () =>
|
||||
useFeedStore((state) => state.activeList)
|
||||
|
|
|
|||
|
|
@ -1 +1,4 @@
|
|||
export * from "./entry"
|
||||
export * from "./feed"
|
||||
export * from "./subscription"
|
||||
export * from "./unread"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,58 @@
|
|||
import type { FeedViewType } from "@renderer/lib/enum"
|
||||
import type { SubscriptionModel } from "@renderer/models"
|
||||
import { apiClient } from "@renderer/queries/api-fetch"
|
||||
import { produce } from "immer"
|
||||
import { omit } from "lodash-es"
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
|
||||
import { zustandStorage } from "./utils/helper"
|
||||
|
||||
type FeedId = string
|
||||
interface SubscriptionState {
|
||||
data: Record<FeedId, SubscriptionModel>
|
||||
}
|
||||
interface SubscriptionActions {
|
||||
upsert: (feedId: FeedId, subscription: SubscriptionModel) => void
|
||||
fetchByView: (view?: FeedViewType) => Promise<SubscriptionModel[]>
|
||||
}
|
||||
export const useSubscriptionStore = create(
|
||||
persist<SubscriptionState & SubscriptionActions>(
|
||||
(set) => ({
|
||||
data: {},
|
||||
|
||||
async fetchByView(view) {
|
||||
const res = await (
|
||||
await apiClient.subscriptions.$get({ query: { view: String(view) } })
|
||||
).json()
|
||||
|
||||
res.data.forEach((subscription: SubscriptionModel) => {
|
||||
set((state) =>
|
||||
produce(state, (state) => {
|
||||
state.data[subscription.feeds.id] = subscription
|
||||
return state
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return res.data
|
||||
},
|
||||
upsert: (feedId, subscription) => {
|
||||
set((state) =>
|
||||
produce(state, (state) => {
|
||||
state.data[feedId] = subscription
|
||||
return state
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "subscription",
|
||||
storage: zustandStorage,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
export const subscriptionActions = {
|
||||
...omit(useSubscriptionStore.getState(), ["data"]),
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import type { FeedViewType } from "@renderer/lib/enum"
|
||||
import { apiClient } from "@renderer/queries/api-fetch"
|
||||
import { produce } from "immer"
|
||||
import { omit } from "lodash-es"
|
||||
import { create } from "zustand"
|
||||
import { persist } from "zustand/middleware"
|
||||
|
||||
import { zustandStorage } from "./utils/helper"
|
||||
|
||||
interface UnreadState {
|
||||
data: Record<string, number>
|
||||
}
|
||||
interface UnreadActions {
|
||||
updateByFeedId: (feedId: string, unread: number) => void
|
||||
fetchUnreadByView: (view?: FeedViewType) => Promise<Record<string, number>>
|
||||
incrementByFeedId: (feedId: string, inc: number) => void
|
||||
}
|
||||
export const useUnreadStore = create(
|
||||
persist<UnreadState & UnreadActions>(
|
||||
(set) => ({
|
||||
data: {},
|
||||
|
||||
async fetchUnreadByView(view) {
|
||||
const unread = await (
|
||||
await apiClient.reads.$get({ query: { view: String(view) } })
|
||||
).json()
|
||||
|
||||
const { data } = unread
|
||||
for (const feedId in data) {
|
||||
set((state) =>
|
||||
produce(state, (state) => {
|
||||
state.data[feedId] = data[feedId]
|
||||
return state
|
||||
}),
|
||||
)
|
||||
}
|
||||
return data
|
||||
},
|
||||
incrementByFeedId: (feedId, inc: number) => {
|
||||
set((state) =>
|
||||
produce(state, (state) => {
|
||||
const cur = state.data[feedId]
|
||||
if (cur === undefined) return state
|
||||
state.data[feedId] = (cur || 0) + inc
|
||||
return state
|
||||
}),
|
||||
)
|
||||
},
|
||||
updateByFeedId: (feedId, unread) => {
|
||||
set((state) =>
|
||||
produce(state, (state) => {
|
||||
state.data[feedId] = unread
|
||||
return state
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "unread",
|
||||
storage: zustandStorage,
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
export const unreadActions = {
|
||||
...omit(useUnreadStore.getState(), ["data"]),
|
||||
}
|
||||
|
||||
Object.assign(window, {
|
||||
__unread() {
|
||||
return useUnreadStore.getState()
|
||||
},
|
||||
})
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { del, get, set } from "idb-keyval"
|
||||
import type { StateStorage } from "zustand/middleware"
|
||||
import { createJSONStorage } from "zustand/middleware"
|
||||
|
||||
export const dbStorage: StateStorage = {
|
||||
getItem: async (name: string): Promise<string | null> =>
|
||||
(await get(name)) || null,
|
||||
setItem: async (name: string, value: string): Promise<void> => {
|
||||
await set(name, value)
|
||||
},
|
||||
removeItem: async (name: string): Promise<void> => {
|
||||
await del(name)
|
||||
},
|
||||
}
|
||||
|
||||
export const zustandStorage = createJSONStorage(() => dbStorage) as any
|
||||
Loading…
Reference in New Issue