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:
Innei 2024-06-04 17:23:00 +08:00 committed by GitHub
parent d5d9ce6feb
commit 6c73deca0e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 458 additions and 310 deletions

View File

@ -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",
},
},
{

View File

@ -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",

View File

@ -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: {}

View File

@ -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"

View File

@ -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"

View File

@ -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>

View File

@ -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
})

View File

@ -1,4 +1,4 @@
import type { EntryModel, FeedModel } from "@renderer/lib/types"
import type { EntryModel, FeedModel } from "@renderer/models"
export type UniversalItemProps = {
entryId: string

View File

@ -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>()

View File

@ -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}

View File

@ -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,

View File

@ -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>
)

View File

@ -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>
)
}

View File

@ -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,

View File

@ -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,

View File

@ -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>
)
},

View File

@ -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;

View File

@ -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)
},
})

View File

@ -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
}

View File

@ -7,6 +7,7 @@ const queryClient = new QueryClient({
queries: {
gcTime: Infinity,
refetchInterval: 1000 * 60 * 10,
throwOnError: import.meta.env.DEV,
},
},
})

View File

@ -0,0 +1 @@
export * from "./types"

View File

@ -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
}[]
}

View File

@ -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"],

View File

@ -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])

View File

@ -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)

View File

@ -1 +1,4 @@
export * from "./entry"
export * from "./feed"
export * from "./subscription"
export * from "./unread"

View File

@ -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"]),
}

View File

@ -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()
},
})

View File

@ -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