diff --git a/eslint.config.mjs b/eslint.config.mjs
index cabac8859..fa04862ad 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -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",
},
},
{
diff --git a/package.json b/package.json
index d11bb8d58..0b272b02d 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 40a96b9cb..24317b402 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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: {}
diff --git a/src/renderer/src/components/discover/form.tsx b/src/renderer/src/components/discover/form.tsx
index 55ab152d9..edae30274 100644
--- a/src/renderer/src/components/discover/form.tsx
+++ b/src/renderer/src/components/discover/form.tsx
@@ -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"
diff --git a/src/renderer/src/components/discover/import.tsx b/src/renderer/src/components/discover/import.tsx
index 941afc4a1..f859e27f8 100644
--- a/src/renderer/src/components/discover/import.tsx
+++ b/src/renderer/src/components/discover/import.tsx
@@ -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"
diff --git a/src/renderer/src/components/entry-column/index.tsx b/src/renderer/src/components/entry-column/index.tsx
index 6246a6dd6..1b2593a05 100644
--- a/src/renderer/src/components/entry-column/index.tsx
+++ b/src/renderer/src/components/entry-column/index.tsx
@@ -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)}
>
- {activeList?.view && views[activeList.view].gridMode ?
- (
-
- ) :
- (
-
- )}
+ {activeList?.view && views[activeList.view].gridMode ? (
+
+ ) : (
+
+ )}
)
}
@@ -197,9 +196,9 @@ const ListHeader: FC = () => {
return (
-
{activeList?.name}
+
{activeList?.name}
- {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 ?
- (
-
- ) :
- (
-
- )}
+ {unreadOnly ? (
+
+ ) : (
+
+ )}
diff --git a/src/renderer/src/components/entry-column/item-wrapper.tsx b/src/renderer/src/components/entry-column/item-wrapper.tsx
index 3f78b54d5..627c663ad 100644
--- a/src/renderer/src/components/entry-column/item-wrapper.tsx
+++ b/src/renderer/src/components/entry-column/item-wrapper.tsx
@@ -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
})
diff --git a/src/renderer/src/components/entry-column/types.ts b/src/renderer/src/components/entry-column/types.ts
index f918ceba3..2a19a362e 100644
--- a/src/renderer/src/components/entry-column/types.ts
+++ b/src/renderer/src/components/entry-column/types.ts
@@ -1,4 +1,4 @@
-import type { EntryModel, FeedModel } from "@renderer/lib/types"
+import type { EntryModel, FeedModel } from "@renderer/models"
export type UniversalItemProps = {
entryId: string
diff --git a/src/renderer/src/components/entry-content/index.tsx b/src/renderer/src/components/entry-content/index.tsx
index b3c1c156d..19638f20b 100644
--- a/src/renderer/src/components/entry-content/index.tsx
+++ b/src/renderer/src/components/entry-content/index.tsx
@@ -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()
diff --git a/src/renderer/src/components/feed-column/category.tsx b/src/renderer/src/components/feed-column/category.tsx
index 39792c8db..0af56ce91 100644
--- a/src/renderer/src/components/feed-column/category.tsx
+++ b/src/renderer/src/components/feed-column/category.tsx
@@ -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 (
{
- 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({
{data.name}
)}
- {setActiveList && {data.name}}
+ {!!setActiveList && {data.name}}
- {!!data.unread && (
-
{data.unread}
+ {!!unread && (
+
{unread}
)}
- {data.list.map((feed) => (
+ {sortByUnreadFeedList.map((feed) => (
- 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,
diff --git a/src/renderer/src/components/feed-column/item.tsx b/src/renderer/src/components/feed-column/item.tsx
index ba985a467..8ff4fd6f6 100644
--- a/src/renderer/src/components/feed-column/item.tsx
+++ b/src/renderer/src/components/feed-column/item.tsx
@@ -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 (
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({
)}
- {!!feed.unread && (
- {feed.unread}
+ {!!feedUnread && (
+ {feedUnread}
)}
)
diff --git a/src/renderer/src/components/feed-column/list.tsx b/src/renderer/src/components/feed-column/list.tsx
index 9a8e01901..4d5781c11 100644
--- a/src/renderer/src/components/feed-column/list.tsx
+++ b/src/renderer/src/components/feed-column/list.tsx
@@ -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
= {}
+ 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 (
{!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}
- {expansion ?
- (
- setExpansion(false)}
- />
- ) :
- (
- setExpansion(true)}
- />
- )}
- {subscriptions.data?.unread}
+ {expansion ? (
+ setExpansion(false)}
+ />
+ ) : (
+ setExpansion(true)}
+ />
+ )}
+ {totalUnread}
)}
- {subscriptions.data?.list?.length ?
- subscriptions.data?.list.map((category) => (
+ {data?.list?.length ? (
+ sortedByUnread?.map((category) => (
- )) :
- (
-
-
-
- Add some feeds
-
-
- )}
+ ))
+ ) : (
+
+
+
+ Add some feeds
+
+
+ )}
)
}
diff --git a/src/renderer/src/components/feed-icon.tsx b/src/renderer/src/components/feed-icon.tsx
index c1d209f69..611a565f1 100644
--- a/src/renderer/src/components/feed-icon.tsx
+++ b/src/renderer/src/components/feed-icon.tsx
@@ -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,
diff --git a/src/renderer/src/components/feed-summary.tsx b/src/renderer/src/components/feed-summary.tsx
index 897050b08..99439bfb8 100644
--- a/src/renderer/src/components/feed-summary.tsx
+++ b/src/renderer/src/components/feed-summary.tsx
@@ -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,
diff --git a/src/renderer/src/components/ui/autocomplete.tsx b/src/renderer/src/components/ui/autocomplete.tsx
index e52b81f2d..a740f81a9 100644
--- a/src/renderer/src/components/ui/autocomplete.tsx
+++ b/src/renderer/src/components/ui/autocomplete.tsx
@@ -60,29 +60,31 @@ export const AutoComplete = React.forwardRef(
}}
ref={ref}
/>
-
-
- {options.map((option) => (
- handleSelectOption(option)}
- className="text-zinc-800"
- onMouseDown={(event) => {
- event.preventDefault()
- event.stopPropagation()
- }}
- >
- {option}
-
- ))}
-
-
+ {options.length > 0 && (
+
+
+ {options.map((option) => (
+ handleSelectOption(option)}
+ className="text-zinc-800"
+ onMouseDown={(event) => {
+ event.preventDefault()
+ event.stopPropagation()
+ }}
+ >
+ {option}
+
+ ))}
+
+
+ )}
)
},
diff --git a/src/renderer/src/hono.ts b/src/renderer/src/hono.ts
index d9241a9f0..a049068c6 100644
--- a/src/renderer/src/hono.ts
+++ b/src/renderer/src/hono.ts
@@ -36,10 +36,7 @@ declare const routes: hono_hono_base.HonoBase;
};
outputFormat: "json";
status: 200;
@@ -451,7 +448,7 @@ declare const routes: hono_hono_base.HonoBase {
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)
},
})
diff --git a/src/renderer/src/hooks/useUpdateEntry.tsx b/src/renderer/src/hooks/useUpdateEntry.tsx
deleted file mode 100644
index bcefae773..000000000
--- a/src/renderer/src/hooks/useUpdateEntry.tsx
+++ /dev/null
@@ -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) => {
- 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>
- 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(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(key, finaldata)
- })
- }
- }
-
- return updateEntry
-}
diff --git a/src/renderer/src/lib/query-client.ts b/src/renderer/src/lib/query-client.ts
index b3920edf3..96c978ff2 100644
--- a/src/renderer/src/lib/query-client.ts
+++ b/src/renderer/src/lib/query-client.ts
@@ -7,6 +7,7 @@ const queryClient = new QueryClient({
queries: {
gcTime: Infinity,
refetchInterval: 1000 * 60 * 10,
+ throwOnError: import.meta.env.DEV,
},
},
})
diff --git a/src/renderer/src/models/index.ts b/src/renderer/src/models/index.ts
new file mode 100644
index 000000000..51f739d01
--- /dev/null
+++ b/src/renderer/src/models/index.ts
@@ -0,0 +1 @@
+export * from "./types"
diff --git a/src/renderer/src/lib/types.ts b/src/renderer/src/models/types.ts
similarity index 89%
rename from src/renderer/src/lib/types.ts
rename to src/renderer/src/models/types.ts
index 2ec240641..f6a1f8ec6 100644
--- a/src/renderer/src/lib/types.ts
+++ b/src/renderer/src/models/types.ts
@@ -65,3 +65,12 @@ export type DataResponse = {
}
export type ActiveEntry = string | null
+
+export type SubscriptionModel = SubscriptionResponse[number]
+
+export type FeedListModel = {
+ list: {
+ list: SubscriptionResponse
+ name: string
+ }[]
+}
diff --git a/src/renderer/src/queries/subscriptions.ts b/src/renderer/src/queries/subscriptions.ts
index e36bfb0da..031202e85 100644
--- a/src/renderer/src/queries/subscriptions.ts
+++ b/src/renderer/src/queries/subscriptions.ts
@@ -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 = {}
- 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"],
diff --git a/src/renderer/src/store/entry.ts b/src/renderer/src/store/entry.ts
index af7030ff5..7fdc2fd7c 100644
--- a/src/renderer/src/store/entry.ts
+++ b/src/renderer/src/store/entry.ts
@@ -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>
@@ -26,14 +31,15 @@ interface EntryActions {
optimisticUpdate: (entryId: string, changed: Partial) => void
optimisticUpdateAll: (changed: Partial) => void
getFlattenMapEntries: () => Record
+ markRead: (feedId: string, entryId: string, read: boolean) => void
}
-export const useEntryStore = create(
- (set, get) => ({
- entries: {},
- flatMapEntries: {},
+export const useEntryStore = create(
+ persist(
+ (set, get) => ({
+ entries: {},
+ flatMapEntries: {},
- actions: {
fetchEntries: async ({
level,
id,
@@ -65,7 +71,7 @@ export const useEntryStore = create(
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(
}),
)
},
+
+ 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])
diff --git a/src/renderer/src/store/feed.ts b/src/renderer/src/store/feed.ts
index 9e77dfca1..e831b540c 100644
--- a/src/renderer/src/store/feed.ts
+++ b/src/renderer/src/store/feed.ts
@@ -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((set) => ({
export const feedActions = useFeedStore.getState().actions
/** Hooks */
-export const useFeedActiveList = () => useFeedStore((state) => state.activeList)
+export const useFeedActiveList = () =>
+ useFeedStore((state) => state.activeList)
diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts
index 6d2abb2e4..f802785dc 100644
--- a/src/renderer/src/store/index.ts
+++ b/src/renderer/src/store/index.ts
@@ -1 +1,4 @@
+export * from "./entry"
export * from "./feed"
+export * from "./subscription"
+export * from "./unread"
diff --git a/src/renderer/src/store/subscription.ts b/src/renderer/src/store/subscription.ts
new file mode 100644
index 000000000..ede0d3500
--- /dev/null
+++ b/src/renderer/src/store/subscription.ts
@@ -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
+}
+interface SubscriptionActions {
+ upsert: (feedId: FeedId, subscription: SubscriptionModel) => void
+ fetchByView: (view?: FeedViewType) => Promise
+}
+export const useSubscriptionStore = create(
+ persist(
+ (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"]),
+}
diff --git a/src/renderer/src/store/unread.ts b/src/renderer/src/store/unread.ts
new file mode 100644
index 000000000..cffa511ea
--- /dev/null
+++ b/src/renderer/src/store/unread.ts
@@ -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
+}
+interface UnreadActions {
+ updateByFeedId: (feedId: string, unread: number) => void
+ fetchUnreadByView: (view?: FeedViewType) => Promise>
+ incrementByFeedId: (feedId: string, inc: number) => void
+}
+export const useUnreadStore = create(
+ persist(
+ (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()
+ },
+})
diff --git a/src/renderer/src/store/utils/helper.ts b/src/renderer/src/store/utils/helper.ts
new file mode 100644
index 000000000..669beeeda
--- /dev/null
+++ b/src/renderer/src/store/utils/helper.ts
@@ -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 =>
+ (await get(name)) || null,
+ setItem: async (name: string, value: string): Promise => {
+ await set(name, value)
+ },
+ removeItem: async (name: string): Promise => {
+ await del(name)
+ },
+}
+
+export const zustandStorage = createJSONStorage(() => dbStorage) as any