Merge branch 'refactor/feeds-route'

This commit is contained in:
DIYgod 2024-06-19 23:18:03 +08:00
commit 3c2848dfa2
No known key found for this signature in database
47 changed files with 598 additions and 320 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path stroke="#10161F" stroke-linecap="round" stroke-width="2" d="M4 20 20 4"/></svg>

After

Width:  |  Height:  |  Size: 216 B

View File

@ -1 +1,2 @@
export * from "./dom"
export * from "./route"

View File

@ -0,0 +1,38 @@
/* eslint-disable unicorn/no-unreadable-array-destructuring */
import { createAtomHooks } from "@renderer/lib/jotai"
import { atom, useAtomValue } from "jotai"
import { selectAtom } from "jotai/utils"
import { useMemo } from "react"
import type { NavigateFunction, Params } from "react-router-dom"
interface RouteAtom {
params: Readonly<Params<string>>
searchParams: URLSearchParams
}
export const [routeAtom, , , , getReadonlyRoute, setRoute] = createAtomHooks(
atom<RouteAtom>({
params: {},
searchParams: new URLSearchParams(),
}),
)
const noop = []
export const useReadonlyRouteSelector = <T>(
selector: (route: RouteAtom) => T,
deps: any[] = noop,
): T =>
useAtomValue(
useMemo(() => selectAtom(routeAtom, (route) => selector(route)), deps),
)
// VITE HMR will create new router instance, but RouterProvider always stable
const [, , , , navigate, setNavigate] = createAtomHooks(
atom<{ fn: NavigateFunction | null }>({ fn() {} }),
)
const getStableRouterNavigate = () => navigate().fn
export {
getStableRouterNavigate,
setNavigate,
}

View File

@ -10,6 +10,7 @@ import { cn } from "@renderer/lib/utils"
import { useSession } from "@renderer/queries/auth"
import { m } from "framer-motion"
import type { FC } from "react"
import { memo } from "react"
import { Link } from "react-router-dom"
import { FollowIcon } from "./icons/follow"
@ -102,7 +103,7 @@ export const LoginButton: FC<LoginProps> = (props) => {
</Link>
)
}
export const ProfileButton: FC<LoginProps> = (props) => {
export const ProfileButton: FC<LoginProps> = memo((props) => {
const { status } = useSession()
if (status !== "authenticated") {
@ -115,7 +116,8 @@ export const ProfileButton: FC<LoginProps> = (props) => {
</ActionButton>
</Link>
)
}
})
ProfileButton.displayName = "ProfileButton"
export function UserButton({
className,

View File

@ -1,10 +1,11 @@
import { FEED_COLLECTION_LIST, levels } from "@renderer/lib/constants"
import type { EntryModel } from "@renderer/models"
import { useFeedStore } from "@renderer/store"
import { useRouteParamsSelector } from "./useRouteParams"
export function useAsRead(entry?: EntryModel) {
const activeList = useFeedStore((state) => state.activeList)
if (!entry) return false
return entry.read && !(activeList?.level === levels.folder && activeList?.id === FEED_COLLECTION_LIST)
return useRouteParamsSelector(({ feedId, level }) => {
if (!entry) return false
return entry.read && !(level === levels.folder && feedId === FEED_COLLECTION_LIST)
}, [entry?.read])
}

View File

@ -1,7 +1,7 @@
import { apiClient } from "@renderer/lib/api-fetch"
import { client } from "@renderer/lib/client"
import type { EntryModel } from "@renderer/models"
import { entryActions } from "@renderer/store/entry/entry"
import { entryActions } from "@renderer/store"
import { useMutation, useQuery } from "@tanstack/react-query"
import type { FetchError } from "ofetch"
import { ofetch } from "ofetch"

View File

@ -0,0 +1,46 @@
/* eslint-disable @typescript-eslint/no-unused-expressions */
import { getReadonlyRoute, getStableRouterNavigate } from "@renderer/atoms"
import { ROUTE_FEED_PENDING } from "@renderer/lib/constants"
import type { FeedViewType } from "@renderer/lib/enum"
import { isUndefined } from "lodash-es"
import { useCallback } from "react"
type NavigateEntryOptions = Partial<{
feedId: string | null
entryId: string | null
view: FeedViewType
level: string | null
category: string | null
}>
/**
* @description a hook to navigate to `feedId`, `entryId`, add search for `view`, `level`
*/
export const useNavigateEntry = () => useCallback((options: NavigateEntryOptions) => {
const { entryId, feedId, level, view, category } = options || {}
const { params, searchParams } = getReadonlyRoute()
let finalFeedId = feedId || params.feedId || ROUTE_FEED_PENDING
if ("feedId" in options && feedId === null) {
finalFeedId = ROUTE_FEED_PENDING
}
const nextSearchParams = new URLSearchParams(searchParams)
!isUndefined(view) && nextSearchParams.set("view", view.toString())
level && nextSearchParams.set("level", level.toString())
if ("category" in options) {
if (!category) {
nextSearchParams.delete("category")
} else {
nextSearchParams.set("category", category.toString())
}
}
return getStableRouterNavigate()?.(
`/feeds/${finalFeedId}/${
entryId || ROUTE_FEED_PENDING
}?${nextSearchParams.toString()}`,
)
}, [])

View File

@ -0,0 +1,85 @@
import { useReadonlyRouteSelector } from "@renderer/atoms"
import { FeedViewType } from "@renderer/lib/enum"
import { useParams, useSearchParams } from "react-router-dom"
// '0', '1', '2', '3', '4', '5',
const FeedViewTypeValues = (() => {
const values = Object.values(FeedViewType)
return values.slice(values.length / 2).map((v) => v.toString())
})()
export const useRouteView = () => {
const [search] = useSearchParams()
const view = search.get("view")
return (
(view && FeedViewTypeValues.includes(view) ?
+view :
FeedViewType.Articles) || FeedViewType.Articles
)
}
export const useRouteEntryId = () => {
const { entryId } = useParams()
return entryId
}
export const useRouteFeedId = () => {
const { feedId } = useParams()
return feedId
}
export const useRouteParms = () => {
const params = useParams()
const [search] = useSearchParams()
const view = useRouteView()
let feedId: string | number = params.feedId!
// If feedId is a number, it's a FeedViewType
if (feedId && FeedViewTypeValues.includes(feedId as string)) {
feedId = Number.parseInt(feedId as string)
}
return {
view,
entryId: params.entryId || undefined,
feedId: params.feedId || undefined,
level: search.get("level") || undefined,
category: search.get("category") || undefined,
}
}
const noop = [] as any[]
export const useRouteParamsSelector = <T>(
selector: (params: {
entryId: string | undefined
feedId: string | undefined
level: string | undefined
category: string | undefined
view: FeedViewType
}) => T,
deps = noop,
): T =>
useReadonlyRouteSelector((route) => {
const { searchParams, params } = route
let feedId: string | number = params.feedId!
// If feedId is a number, it's a FeedViewType
if (feedId && FeedViewTypeValues.includes(feedId as string)) {
feedId = Number.parseInt(feedId as string)
}
const view = searchParams.get("view")
const finalView =
(view && FeedViewTypeValues.includes(view) ?
+view :
FeedViewType.Articles) || FeedViewType.Articles
return selector({
entryId: params.entryId || undefined,
feedId: params.feedId || undefined,
level: searchParams.get("level") || undefined,
category: searchParams.get("category") || undefined,
view: finalView,
})
}, deps)

View File

@ -7,6 +7,7 @@ import { ofetch } from "ofetch"
export abstract class RequestError extends Error {
name = "RequestError"
}
const csrfToken = await getCsrfToken()
export const apiFetch = ofetch.create({
baseURL: import.meta.env.VITE_API_URL,
@ -14,8 +15,6 @@ export const apiFetch = ofetch.create({
retry: false,
onRequest: async ({ options }) => {
if (options.method && options.method.toLowerCase() !== "get") {
const csrfToken = await getCsrfToken()
if (typeof options.body === "string") {
options.body = JSON.parse(options.body)
}

View File

@ -74,9 +74,12 @@ export const settingTabs = [
},
]
////
/// App
export const APP_NAME = "Follow"
/// Feed
export const FEED_COLLECTION_LIST = "collections"
/// Local storage keys
export const QUERY_PERSIST_KEY = buildStorageNS("REACT_QUERY_OFFLINE_CACHE")
/// Route Keys
export const ROUTE_FEED_PENDING = "pending"

View File

@ -10,8 +10,8 @@ describe("test route builder", () => {
"./pages/(external)/layout.tsx": fakePromise,
"./pages/(external)/(with-layout)/index.tsx": fakePromise,
"./pages/(external)/(with-layout)/layout.tsx": fakePromise,
"./pages/(external)/(with-layout)/feed/[:id]/index.tsx": fakePromise,
"./pages/(external)/(with-layout)/feed/[:id]/layout.tsx": fakePromise,
"./pages/(external)/(with-layout)/feed/[id]/index.tsx": fakePromise,
"./pages/(external)/(with-layout)/feed/[id]/layout.tsx": fakePromise,
"./pages/(main)/layout.tsx": fakePromise,
"./pages/(main)/(context)/layout.tsx": fakePromise,
@ -118,7 +118,7 @@ describe("test route builder", () => {
"children": [
{
"handle": {
"fs": "./pages/(external)/(with-layout)/feed/[:id]/index/",
"fs": "./pages/(external)/(with-layout)/feed/[id]/index/",
"fullPath": "/feed/:id/",
},
"lazy": [Function],
@ -126,7 +126,7 @@ describe("test route builder", () => {
},
],
"handle": {
"fs": "./pages/(external)/(with-layout)/feed/[:id]/layout",
"fs": "./pages/(external)/(with-layout)/feed/[id]/layout",
"fullPath": "/feed/:id",
},
"lazy": [Function],
@ -134,7 +134,7 @@ describe("test route builder", () => {
},
],
"handle": {
"fs": "./pages/(external)/(with-layout)/feed/[:id]/:id",
"fs": "./pages/(external)/(with-layout)/feed/[id]/:id",
"fullPath": "/feed/:id",
},
"path": ":id",

View File

@ -189,8 +189,8 @@ const normalizePathKey = (key: string) => {
return ""
}
if (key.startsWith("[:") && key.endsWith("]")) {
return `:${key.slice(2, -1)}`
if (key.startsWith("[") && key.endsWith("]")) {
return `:${key.slice(1, -1)}`
}
return key
}

View File

@ -51,9 +51,10 @@ export type DiscoverResponse = Array<
>[number]
>
export type ActionsResponse = Exclude<ExtractBizResponse<
typeof apiClient.actions.$get
>["data"], undefined>["rules"]
export type ActionsResponse = Exclude<
ExtractBizResponse<typeof apiClient.actions.$get>["data"],
undefined
>["rules"]
export type ListResponse<T> = {
code: number
@ -67,7 +68,7 @@ export type DataResponse<T> = {
data?: T
}
export type ActiveEntryId = string | null
export type ActiveEntryId = Nullable<string>
export type SubscriptionModel = SubscriptionResponse[number]
@ -78,4 +79,6 @@ export type FeedListModel = {
}[]
}
export type SupportedLanguages = Parameters<typeof apiClient.ai.translation.$get>[0]["query"]["language"]
export type SupportedLanguages = Parameters<
typeof apiClient.ai.translation.$get
>[0]["query"]["language"]

View File

@ -1,7 +1,4 @@
import {
ActionButton,
StyledButton,
} from "@renderer/components/ui/button"
import { ActionButton, StyledButton } from "@renderer/components/ui/button"
import {
Popover,
PopoverClose,
@ -9,6 +6,11 @@ import {
PopoverTrigger,
} from "@renderer/components/ui/popover"
import { useRead, useRefValue } from "@renderer/hooks"
import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry"
import {
useRouteEntryId,
useRouteParms,
} from "@renderer/hooks/biz/useRouteParams"
import { apiClient } from "@renderer/lib/api-fetch"
import { views } from "@renderer/lib/constants"
import { buildStorageNS } from "@renderer/lib/ns"
@ -16,12 +18,10 @@ import { shortcuts } from "@renderer/lib/shortcuts"
import { getEntriesParams } from "@renderer/lib/utils"
import { useEntries } from "@renderer/queries/entries"
import {
feedActions,
getCurrentEntryId,
entryActions,
subscriptionActions,
useFeedStore,
useFeedHeaderTitle,
} from "@renderer/store"
import { entryActions } from "@renderer/store/entry/entry"
import {
useEntry,
useEntryIdsByFeedIdOrView,
@ -44,7 +44,6 @@ import { useHotkeys } from "react-hotkeys-hook"
import type { ListRange, VirtuosoHandle, VirtuosoProps } from "react-virtuoso"
import { Virtuoso, VirtuosoGrid } from "react-virtuoso"
import { useEventCallback } from "usehooks-ts"
import { useShallow } from "zustand/react/shallow"
import { EmptyIcon } from "../../components/icons/empty"
import { LoadingCircle } from "../../components/ui/loading"
@ -60,13 +59,9 @@ const unreadOnlyAtom = atomWithStorage<boolean>(
)
export function EntryColumn() {
const { activeList, activeEntryId } = useFeedStore((state) => ({
activeList: state.activeList,
activeEntryId: state.activeEntryId,
}))
const entries = useEntriesByView()
const { entriesIds, isFetchingNextPage } = entries
const { entryId: activeEntryId, view, feedId } = useRouteParms()
const activeEntry = useEntry(activeEntryId)
const markReadMutation = useRead()
useEffect(() => {
@ -131,23 +126,25 @@ export function EntryColumn() {
(_, entryId: string) => {
if (!entryId) return null
return (
<EntryItem key={entryId} entryId={entryId} view={activeList?.view} />
)
return <EntryItem key={entryId} entryId={entryId} view={view} />
},
[activeList?.view],
[view],
),
}
const navigate = useNavigateEntry()
return (
<div
className="relative flex h-full flex-1 flex-col"
onClick={() => feedActions.setActiveEntry(null)}
onClick={() =>
navigate({
entryId: null,
})}
data-total-count={virtuosoOptions.totalCount}
>
<ListHeader totalCount={virtuosoOptions.totalCount} />
<m.div
key={`${activeList?.id}-${activeList?.view}`}
key={`${feedId}-${view}`}
className="h-full"
initial={{ opacity: 0.01, y: 100 }}
animate={{ opacity: 1, y: 0 }}
@ -155,7 +152,7 @@ export function EntryColumn() {
>
{virtuosoOptions.totalCount === 0 ? (
<EmptyList />
) : activeList?.view && views[activeList.view].gridMode ?
) : view && views[view].gridMode ?
(
<VirtuosoGrid
listClassName="grid grid-cols-2 gap-3 md:grid-cols-3 lg:grid-cols-4 px-4"
@ -171,16 +168,16 @@ export function EntryColumn() {
}
const useEntriesByView = () => {
const activeList = useFeedStore(useShallow((state) => state.activeList))
const activeList = useRouteParms()
const unreadOnly = useAtomValue(unreadOnlyAtom)
const query = useEntries({
level: activeList?.level,
id: activeList.id,
id: activeList.feedId,
view: activeList?.view,
...(unreadOnly === true && { read: false }),
})
const entries = useEntryIdsByFeedIdOrView(activeList.id, {
const entries = useEntryIdsByFeedIdOrView(activeList.feedId!, {
unread: unreadOnly,
})
@ -194,7 +191,7 @@ const useEntriesByView = () => {
useEffect(() => {
prevEntries.current = []
}, [activeList.id])
}, [activeList.feedId])
const localEntries = useMemo(() => {
if (!unreadOnly) {
prevEntries.current = []
@ -242,31 +239,31 @@ const useEntriesByView = () => {
const ListHeader: FC<{
totalCount: number
}> = ({ totalCount }) => {
const activeList = useFeedStore(useShallow((state) => state.activeList))
const routerParams = useRouteParms()
const [unreadOnly, setUnreadOnly] = useAtom(unreadOnlyAtom)
const [markPopoverOpen, setMarkPopoverOpen] = useState(false)
const handleMarkAllAsRead = useCallback(async () => {
if (!activeList) return
if (!routerParams) return
await apiClient.reads.all.$post({
json: {
...getEntriesParams({
level: activeList?.level,
id: activeList?.id,
view: activeList?.view,
level: routerParams?.level,
id: routerParams?.feedId,
view: routerParams?.view,
}),
},
})
if (typeof activeList.id === "number") {
subscriptionActions.markReadByView(activeList.view)
if (typeof routerParams.feedId === "number") {
subscriptionActions.markReadByView(routerParams.view)
} else {
activeList.id.split(",").forEach((feedId) => {
routerParams.feedId?.split(",").forEach((feedId) => {
entryActions.markReadByFeedId(feedId)
})
}
setMarkPopoverOpen(false)
}, [activeList])
}, [routerParams])
useHotkeys(shortcuts.entries.markAllAsRead.key, () => {
setMarkPopoverOpen(true)
@ -276,6 +273,7 @@ const ListHeader: FC<{
setUnreadOnly((prev) => !prev)
}, { scopes: ["home"] })
const headerTitle = useFeedHeaderTitle()
return (
<div className="mb-5 flex w-full flex-col pl-11 pr-4 pt-2.5">
<div className="flex w-full justify-end">
@ -312,7 +310,7 @@ const ListHeader: FC<{
</div>
</div>
<div>
<div className="text-lg font-bold leading-none">{activeList?.name}</div>
<div className="text-lg font-bold leading-none">{headerTitle}</div>
<div className="text-xs font-medium text-zinc-400">
{totalCount || 0}
{" "}
@ -362,10 +360,14 @@ const EntryList: FC<VirtuosoProps<string, unknown>> = ({
const dataRef = useRefValue(virtuosoOptions.data!)
const currentEntryIdRef = useRefValue(useRouteEntryId())
const navigate = useNavigateEntry()
useHotkeys(shortcuts.entries.next.key, () => {
const data = dataRef.current
const currentActiveEntryIndex = data.indexOf(
getCurrentEntryId() || "",
currentEntryIdRef.current || "",
)
const nextIndex = Math.min(currentActiveEntryIndex + 1, data.length - 1)
@ -374,13 +376,16 @@ const EntryList: FC<VirtuosoProps<string, unknown>> = ({
index: nextIndex,
})
const nextId = data![nextIndex]
feedActions.setActiveEntry(nextId)
navigate({
entryId: nextId,
})
}, { scopes: ["home"] })
useHotkeys(shortcuts.entries.previous.key, () => {
const data = dataRef.current
const currentActiveEntryIndex = data.indexOf(
getCurrentEntryId() || "",
currentEntryIdRef.current || "",
)
const nextIndex = currentActiveEntryIndex === -1 ? data.length - 1 : Math.max(0, currentActiveEntryIndex - 1)
@ -389,7 +394,10 @@ const EntryList: FC<VirtuosoProps<string, unknown>> = ({
index: nextIndex,
})
const nextId = data![nextIndex]
feedActions.setActiveEntry(nextId)
navigate({
entryId: nextId,
})
}, { scopes: ["home"] })
return (

View File

@ -1,11 +1,12 @@
import { useAsRead, useBizQuery, useEntryActions } from "@renderer/hooks"
import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry"
import { useRouteParamsSelector } from "@renderer/hooks/biz/useRouteParams"
import { views } from "@renderer/lib/constants"
import { FeedViewType } from "@renderer/lib/enum"
import { showNativeMenu } from "@renderer/lib/native-menu"
import { cn } from "@renderer/lib/utils"
import type { EntryModel } from "@renderer/models"
import { Queries } from "@renderer/queries"
import { feedActions, useFeedStore } from "@renderer/store"
import { useEntry } from "@renderer/store/entry/hooks"
import type { FC } from "react"
import { memo, useCallback } from "react"
@ -40,7 +41,7 @@ function EntryItemImpl({ entry, view }: { entry: EntryModel, view?: number }) {
},
)
const activeEntry = useFeedStore((state) => state.activeEntryId)
const isActive = useRouteParamsSelector(({ entryId }) => entryId === entry.entries.id)
const asRead = useAsRead(entry)
@ -75,14 +76,17 @@ function EntryItemImpl({ entry, view }: { entry: EntryModel, view?: number }) {
Item = ArticleItem
}
}
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> =
useCallback(() => {}, [])
const navigate = useNavigateEntry()
const handleClick: React.MouseEventHandler<HTMLDivElement> = useCallback(
(e) => {
e.stopPropagation()
feedActions.setActiveEntry(entry.entries.id)
navigate({
entryId: entry.entries.id,
})
},
[entry.entries.id],
[entry.entries.id, navigate],
)
const handleDoubleClick: React.MouseEventHandler<HTMLDivElement> =
useCallback(
@ -116,11 +120,10 @@ function EntryItemImpl({ entry, view }: { entry: EntryModel, view?: number }) {
className={cn(
"rounded-md bg-theme-background transition-colors",
!views[view || 0].wideMode &&
activeEntry === entry.entries.id &&
isActive &&
"bg-theme-item-active",
asRead ? "text-zinc-500/90" : "text-zinc-900 dark:text-white/90",
)}
onKeyDown={handleKeyDown}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onContextMenu={handleContextMenu}

View File

@ -8,7 +8,7 @@ import {
WrappedElementProvider,
} from "@renderer/providers/wrapped-element-provider"
import { Queries } from "@renderer/queries"
import { useEntry, useFeedStore } from "@renderer/store"
import { useEntry, useFeedHeaderTitle } from "@renderer/store"
import { m } from "framer-motion"
import { useEffect, useState } from "react"
@ -17,10 +17,9 @@ import { EntryTranslation } from "../entry-column/translation"
import { setEntryTitleMeta } from "./atoms"
import { EntryHeader } from "./header"
export const EntryContent = ({ entry }: { entry: ActiveEntryId }) => {
const activeList = useFeedStore((state) => state.activeList)
if (!entry) {
export const EntryContent = ({ entryId }: { entryId: ActiveEntryId }) => {
const title = useFeedHeaderTitle()
if (!entryId) {
return (
<m.div
className="-mt-2 flex size-full min-w-0 flex-col items-center justify-center gap-1 text-lg font-medium text-zinc-400"
@ -28,12 +27,12 @@ export const EntryContent = ({ entry }: { entry: ActiveEntryId }) => {
animate={{ opacity: 1, y: 0 }}
>
<Logo className="size-16 opacity-40 grayscale" />
{activeList?.name}
{title}
</m.div>
)
}
return <EntryContentRender entryId={entry} />
return <EntryContentRender entryId={entryId} />
}
function EntryContentRender({ entryId }: { entryId: string }) {
@ -169,7 +168,9 @@ const TitleMetaHandler: Component<{
} = useEntry(entryId)!
useEffect(() => {
if (!isAtTop && entryTitle && feedTitle) { setEntryTitleMeta({ title: entryTitle, description: feedTitle }) }
if (!isAtTop && entryTitle && feedTitle) {
setEntryTitleMeta({ title: entryTitle, description: feedTitle })
}
return () => {
setEntryTitleMeta(null)
}

View File

@ -2,28 +2,23 @@ import {
Collapsible,
CollapsibleTrigger,
} from "@renderer/components/ui/collapsible"
import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry"
import { useRouteParamsSelector } from "@renderer/hooks/biz/useRouteParams"
import { levels } from "@renderer/lib/constants"
import { stopPropagation } from "@renderer/lib/dom"
import { showNativeMenu } from "@renderer/lib/native-menu"
import { cn } from "@renderer/lib/utils"
import type { FeedListModel } from "@renderer/models"
import {
feedActions,
useFeedActiveList,
useUnreadStore,
} from "@renderer/store"
import { useUnreadStore } from "@renderer/store"
import { AnimatePresence, m } from "framer-motion"
import { useEffect, useState } from "react"
import { memo, useEffect, useState } from "react"
import { useModalStack } from "../../components/ui/modal/stacked/hooks"
import { CategoryRemoveDialogContent } from "./category-remove-dialog"
import {
CategoryRenameContent,
} from "./category-rename-dialog"
import { CategoryRenameContent } from "./category-rename-dialog"
import { FeedItem } from "./item"
const { setActiveList } = feedActions
export function FeedCategory({
function FeedCategoryImpl({
data,
view,
expansion,
@ -32,8 +27,6 @@ export function FeedCategory({
view?: number
expansion: boolean
}) {
const activeList = useFeedActiveList()
const [open, setOpen] = useState(!data.name)
const feedIdList = data.list.map((feed) => feed.feedId)
@ -44,13 +37,17 @@ export function FeedCategory({
}
}, [expansion])
const navigate = useNavigateEntry()
const setCategoryActive = () => {
if (view !== undefined) {
setActiveList({
navigate({
entryId: null,
// TODO joint feedId is too long, need to be optimized
feedId: data.list.map((feed) => feed.feedId).join(","),
level: levels.folder,
id: data.list.map((feed) => feed.feedId).join(","),
name: data.name,
view,
category: data.name,
})
}
}
@ -64,6 +61,9 @@ export function FeedCategory({
(a, b) => (state.data[b.feedId] || 0) - (state.data[a.feedId] || 0),
),
)
const isActive = useRouteParamsSelector((routerParams) => routerParams?.level === levels.folder &&
routerParams.feedId === data.list.map((feed) => feed.feedId).join(","))
const { present } = useModalStack()
return (
<Collapsible
@ -75,9 +75,7 @@ export function FeedCategory({
<div
className={cn(
"flex w-full items-center justify-between rounded-md px-2.5 transition-colors",
activeList?.level === levels.folder &&
activeList.name === data.name &&
"bg-native-active",
isActive && "bg-native-active",
)}
onClick={(e) => {
e.stopPropagation()
@ -123,15 +121,14 @@ export function FeedCategory({
>
<div className="flex w-full min-w-0 items-center">
<CollapsibleTrigger
onClick={stopPropagation}
className={cn(
"flex h-8 items-center [&_.i-mgc-right-cute-fi]:data-[state=open]:rotate-90",
!setActiveList && "flex-1",
)}
>
<i className="i-mgc-right-cute-fi mr-2 transition-transform" />
{!setActiveList && <span className="truncate">{data.name}</span>}
</CollapsibleTrigger>
{!!setActiveList && <span className="truncate">{data.name}</span>}
<span className="truncate">{data.name}</span>
</div>
{!!unread && (
<div className="ml-2 text-xs text-zinc-500">{unread}</div>
@ -158,7 +155,7 @@ export function FeedCategory({
{sortByUnreadFeedList.map((feed) => (
<FeedItem
key={feed.feedId}
feed={feed}
subscription={feed}
view={view}
className={data.name ? "pl-6" : "pl-2.5"}
/>
@ -169,3 +166,5 @@ export function FeedCategory({
</Collapsible>
)
}
export const FeedCategory = memo(FeedCategoryImpl)

View File

@ -1,14 +1,14 @@
import { Logo } from "@renderer/components/icons/logo"
import { ActionButton } from "@renderer/components/ui/button"
import { ProfileButton } from "@renderer/components/user-button"
import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry"
import { APP_NAME, levels, views } from "@renderer/lib/constants"
import { stopPropagation } from "@renderer/lib/dom"
import { clamp, cn } from "@renderer/lib/utils"
import { feedActions } from "@renderer/store"
import { useWheel } from "@use-gesture/react"
import { m, useSpring } from "framer-motion"
import { Lethargy } from "lethargy"
import { useEffect, useRef, useState } from "react"
import { useCallback, useEffect, useRef, useState } from "react"
import { Link } from "react-router-dom"
import { Vibrancy } from "../../components/ui/background"
@ -17,7 +17,6 @@ import { FeedList } from "./list"
const lethargy = new Lethargy()
export function FeedColumn() {
const { setActiveList } = feedActions
const carouselRef = useRef<HTMLDivElement>(null)
const [active, setActive] = useState(0)
@ -56,16 +55,20 @@ export function FeedColumn() {
const normalStyle =
!window.electron || window.electron.process.platform !== "darwin"
const navigate = useNavigateEntry()
const navigateBackHome = useCallback(() => {
navigate({
feedId: null,
entryId: null,
view: active,
level: levels.view,
})
}, [active, navigate])
return (
<Vibrancy
className="flex h-full flex-col gap-3 pt-2.5"
onClick={() =>
setActiveList({
level: levels.view,
id: active,
name: views[active].name,
view: active,
})}
onClick={navigateBackHome}
>
<div
className={cn(
@ -79,12 +82,8 @@ export function FeedColumn() {
className="flex items-center gap-1 text-xl font-bold"
onClick={(e) => {
e.stopPropagation()
setActiveList({
level: levels.view,
id: active,
name: views[active].name,
view: active,
})
navigateBackHome()
}}
>
<Logo className="size-6" />
@ -116,11 +115,12 @@ export function FeedColumn() {
)}
onClick={(e) => {
setActive(index)
setActiveList?.({
level: "view",
id: index,
name: views[index].name,
navigate({
feedId: null,
entryId: null,
view: index,
level: levels.view,
})
e.stopPropagation()
}}

View File

@ -8,54 +8,56 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@renderer/components/ui/tooltip"
import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry"
import { useRouteParamsSelector } from "@renderer/hooks/biz/useRouteParams"
import { apiClient } from "@renderer/lib/api-fetch"
import { levels } from "@renderer/lib/constants"
import dayjs from "@renderer/lib/dayjs"
import { showNativeMenu } from "@renderer/lib/native-menu"
import { cn } from "@renderer/lib/utils"
import type { SubscriptionResponse } from "@renderer/models"
import { Queries } from "@renderer/queries"
import {
feedActions,
useFeedActiveList,
useUnreadStore,
} from "@renderer/store"
import type { SubscriptionPlainModel } from "@renderer/store"
import { getFeedById, useFeedById, useUnreadStore } from "@renderer/store"
import { useMutation } from "@tanstack/react-query"
import { memo, useCallback } from "react"
import { toast } from "sonner"
import { FeedForm } from "../discover/feed-form"
export function FeedItem({
feed,
type FeedItemData = SubscriptionPlainModel
const FeedItemImpl = ({
subscription,
view,
className,
}: {
feed: SubscriptionResponse[number]
subscription: FeedItemData
view?: number
className?: string
}) {
const activeList = useFeedActiveList()
const { setActiveList } = feedActions
const setFeedActive = (feed: SubscriptionResponse[number]) => {
if (view === undefined) return
setActiveList({
level: levels.feed,
id: feed.feedId,
name: feed.feeds.title || "",
view,
})
// focus to main container in order to let keyboard can navigate entry items by arrow keys
requestAnimationFrame(() => {
requestAnimationFrame(() => {
getMainContainerElement()?.focus()
}) => {
const navigate = useNavigateEntry()
const handleNavigate: React.MouseEventHandler<HTMLDivElement> = useCallback(
(e) => {
e.stopPropagation()
if (view === undefined) return
navigate({
feedId: subscription.feedId,
entryId: null,
view,
level: levels.feed,
category: null,
})
})
}
// focus to main container in order to let keyboard can navigate entry items by arrow keys
requestAnimationFrame(() => {
requestAnimationFrame(() => {
getMainContainerElement()?.focus()
})
})
},
[subscription.feedId, navigate, view],
)
const deleteMutation = useMutation({
mutationFn: async (feed: SubscriptionResponse[number]) =>
mutationFn: async (feed: SubscriptionPlainModel) =>
apiClient.subscriptions.$delete({
json: {
feedId: feed.feedId,
@ -65,13 +67,17 @@ export function FeedItem({
onSuccess: (_, variables) => {
Queries.subscription.byView(variables.view).invalidate()
const feed = getFeedById(variables.feedId)
if (!feed) return
toast(
<>
Feed
{" "}
<i className="mr-px font-semibold">{variables.feeds.title}</i>
<i className="mr-px font-semibold">{feed.title}</i>
{" "}
has been unfollowed.
has been
unfollowed.
</>,
{
duration: 3000,
@ -80,14 +86,14 @@ export function FeedItem({
onClick: async () => {
await apiClient.subscriptions.$post({
json: {
url: variables.feeds.url,
url: feed.url,
view: variables.view,
category: variables.category,
isPrivate: variables.isPrivate,
},
})
Queries.subscription.byView(feed.view).invalidate()
Queries.subscription.byView(variables.view).invalidate()
},
},
},
@ -95,24 +101,33 @@ export function FeedItem({
},
})
const feedUnread = useUnreadStore((state) => state.data[feed.feedId] || 0)
const feedUnread = useUnreadStore(
(state) => state.data[subscription.feedId] || 0,
)
const { present } = useModalStack()
const isActive = useRouteParamsSelector(
(routerParams) =>
routerParams?.level === levels.feed &&
routerParams.feedId === subscription.feedId,
)
const feed = useFeedById(subscription.feedId)
if (!feed) return null
return (
<div
className={cn(
"flex w-full items-center justify-between rounded-md py-[2px] pr-2.5 text-sm font-medium leading-loose",
activeList?.level === levels.feed &&
activeList.id === feed.feedId &&
"bg-native-active",
isActive && "bg-native-active",
className,
)}
onClick={(e) => {
e.stopPropagation()
setFeedActive(feed)
}}
onClick={handleNavigate}
onDoubleClick={() => {
window.open(
`${import.meta.env.VITE_WEB_URL}/feed/${feed.feedId}?view=${view}`,
`${import.meta.env.VITE_WEB_URL}/feed/${
subscription.feedId
}?view=${view}`,
"_blank",
)
}}
@ -126,14 +141,20 @@ export function FeedItem({
click: () => {
present({
title: "Edit Feed",
content: ({ dismiss }) => <FeedForm asWidget id={feed.feedId} onSuccess={dismiss} />,
content: ({ dismiss }) => (
<FeedForm
asWidget
id={subscription.feedId}
onSuccess={dismiss}
/>
),
})
},
},
{
type: "text",
label: "Unfollow",
click: () => deleteMutation.mutate(feed),
click: () => deleteMutation.mutate(subscription),
},
{
type: "separator",
@ -144,7 +165,7 @@ export function FeedItem({
click: () =>
window.open(
`${import.meta.env.VITE_WEB_URL}/feed/${
feed.feedId
subscription.feedId
}?view=${view}`,
"_blank",
),
@ -152,8 +173,12 @@ export function FeedItem({
{
type: "text",
label: "Open Site in Browser",
click: () =>
feed.feeds.siteUrl && window.open(feed.feeds.siteUrl, "_blank"),
click: () => {
const feed = getFeedById(subscription.feedId)
if (feed) {
feed.siteUrl && window.open(feed.siteUrl, "_blank")
}
},
},
],
e,
@ -163,12 +188,12 @@ export function FeedItem({
<div
className={cn(
"flex min-w-0 items-center",
feed.feeds.errorAt && "text-red-900",
feed.errorAt && "text-red-900",
)}
>
<FeedIcon feed={feed.feeds} className="size-4" />
<div className="truncate">{feed.feeds.title}</div>
{feed.feeds.errorAt && (
<FeedIcon feed={feed} className="size-4" />
<div className="truncate">{feed.title}</div>
{feed.errorAt && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
@ -180,7 +205,7 @@ export function FeedItem({
{" "}
{dayjs
.duration(
dayjs(feed.feeds.errorAt).diff(dayjs(), "minute"),
dayjs(feed.errorAt).diff(dayjs(), "minute"),
"minute",
)
.humanize(true)}
@ -189,7 +214,7 @@ export function FeedItem({
</Tooltip>
</TooltipProvider>
)}
{feed.isPrivate && (
{subscription.isPrivate && (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger asChild>
@ -210,3 +235,5 @@ export function FeedItem({
</div>
)
}
export const FeedItem = memo(FeedItemImpl)

View File

@ -1,13 +1,17 @@
import { useBizQuery } from "@renderer/hooks"
import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry"
import { useRouteFeedId } from "@renderer/hooks/biz/useRouteParams"
import { FEED_COLLECTION_LIST, levels, views } from "@renderer/lib/constants"
import { stopPropagation } from "@renderer/lib/dom"
import type { FeedViewType } from "@renderer/lib/enum"
import { cn } from "@renderer/lib/utils"
import type { FeedListModel, SubscriptionResponse } from "@renderer/models"
import type {
FeedListModel,
} from "@renderer/models"
import { Queries } from "@renderer/queries"
import type { SubscriptionPlainModel } from "@renderer/store"
import {
feedActions,
useFeedActiveList,
getFeedById,
useSubscriptionByView,
useUnreadStore,
} from "@renderer/store"
@ -30,7 +34,7 @@ const useData = (view: FeedViewType) => {
list: Record<
string,
{
list: SubscriptionResponse
list: SubscriptionPlainModel[]
}
>
}
@ -38,8 +42,9 @@ const useData = (view: FeedViewType) => {
const subscriptions = structuredClone(data)
for (const subscription of subscriptions) {
if (!subscription.category && subscription.feeds.siteUrl) {
const { domain } = parse(subscription.feeds.siteUrl)
const feed = getFeedById(subscription.feedId)
if (!subscription.category && feed && feed.siteUrl) {
const { domain } = parse(feed.siteUrl)
if (domain) {
if (!domains[domain]) {
domains[domain] = 0
@ -50,11 +55,13 @@ const useData = (view: FeedViewType) => {
}
for (const subscription of subscriptions) {
const feed = getFeedById(subscription.feedId)
if (!feed) continue
if (!subscription.category) {
if (subscription.feeds.siteUrl) {
if (feed.siteUrl) {
// FIXME @DIYgod
// The logic here makes it impossible to remove the auto-generated category based on domain
const { domain } = parse(subscription.feeds.siteUrl)
const { domain } = parse(feed.siteUrl)
if (domain && domains[domain] > 1) {
subscription.category =
domain.slice(0, 1).toUpperCase() + domain.slice(1)
@ -94,7 +101,6 @@ export function FeedList({
}) {
const [expansion, setExpansion] = useState(false)
const data = useData(view)
const activeList = useFeedActiveList()
useBizQuery(Queries.subscription.unreadAll())
@ -108,8 +114,6 @@ export function FeedList({
return unread
})
const { setActiveList } = feedActions
const sortedByUnread = useUnreadStore((state) =>
data?.list?.sort(
(a, b) =>
@ -118,6 +122,9 @@ export function FeedList({
),
)
const feedId = useRouteFeedId()
const navigate = useNavigateEntry()
return (
<div className={cn(className, "font-medium")}>
{!hideTitle && (
@ -130,10 +137,10 @@ export function FeedList({
onClick={(e) => {
e.stopPropagation()
if (view !== undefined) {
setActiveList({
navigate({
entryId: null,
feedId: null,
level: levels.view,
id: view,
name: views[view].name,
view,
})
}
@ -160,15 +167,15 @@ export function FeedList({
<div
className={cn(
"flex h-8 w-full items-center rounded-md px-2.5 transition-colors",
activeList?.id === FEED_COLLECTION_LIST && "bg-native-active",
feedId === FEED_COLLECTION_LIST && "bg-native-active",
)}
onClick={(e) => {
e.stopPropagation()
if (view !== undefined) {
setActiveList({
level: levels.folder,
id: FEED_COLLECTION_LIST,
name: "Collections",
navigate({
entryId: null,
feedId: FEED_COLLECTION_LIST,
level: levels.feed,
view,
})
}

View File

@ -1,17 +0,0 @@
import { setMainContainerElement } from "@renderer/atoms"
import { FeedColumn } from "@renderer/modules/feed-column"
import { Outlet } from "react-router-dom"
export function Component() {
return (
<div className="flex h-full">
<div className="w-64 shrink-0 border-r">
<FeedColumn />
</div>
{/* NOTE: tabIndex for main element can get by `document.activeElement` */}
<main ref={setMainContainerElement} className="flex min-w-0 flex-1 bg-theme-background !outline-none" tabIndex={-1}>
<Outlet />
</main>
</div>
)
}

View File

@ -0,0 +1,20 @@
import { useRouteView } from "@renderer/hooks/biz/useRouteParams"
import { ROUTE_FEED_PENDING, views } from "@renderer/lib/constants"
import { EntryContent } from "@renderer/modules/entry-content"
import { AnimatePresence } from "framer-motion"
import { useParams } from "react-router-dom"
export const Component = () => {
const { entryId } = useParams()
const view = useRouteView()
const inWideMode = view ? views[view].wideMode : false
return (
<AnimatePresence>
{!inWideMode && (
<div className="min-w-0 flex-1">
<EntryContent entryId={entryId === ROUTE_FEED_PENDING ? "" : entryId} />
</div>
)}
</AnimatePresence>
)
}

View File

@ -1,36 +1,21 @@
import { useRouteView } from "@renderer/hooks/biz/useRouteParams"
import { views } from "@renderer/lib/constants"
import { cn } from "@renderer/lib/utils"
import { EntryColumn } from "@renderer/modules/entry-column"
import { EntryContent } from "@renderer/modules/entry-content"
import {
feedActions,
uiActions,
useFeedStore,
useUIStore,
} from "@renderer/store"
import { AnimatePresence } from "framer-motion"
import { useEffect, useMemo, useRef } from "react"
import { uiActions, useUIStore } from "@renderer/store"
import { useMemo, useRef } from "react"
import { HotkeysProvider } from "react-hotkeys-hook"
import { useResizable } from "react-resizable-layout"
import { useShallow } from "zustand/react/shallow"
import { Outlet } from "react-router-dom"
export function Component() {
const { activeEntry, activeList } = useFeedStore(
useShallow((state) => ({
activeList: state.activeList,
activeEntry: state.activeEntryId,
})),
)
const { setActiveEntry } = feedActions
useEffect(() => {
setActiveEntry(null)
}, [activeList?.id])
const containerRef = useRef<HTMLDivElement>(null)
// Memo this initial value to avoid re-render
// eslint-disable-next-line react-compiler/react-compiler
const entryColWidth = useMemo(() => useUIStore.getState().entryColWidth, [])
const view = useRouteView()
const inWideMode = view ? views[view].wideMode : false
const { position, separatorProps } = useResizable({
axis: "x",
min: 300,
@ -42,7 +27,6 @@ export function Component() {
},
})
const inWideMode = activeList && views[activeList.view].wideMode
return (
<HotkeysProvider initiallyActiveScopes={["home"]}>
<div ref={containerRef} className="flex min-w-0 grow">
@ -64,13 +48,7 @@ export function Component() {
className="h-full w-px shrink-0 cursor-ew-resize hover:bg-border"
/>
)}
<AnimatePresence>
{!inWideMode && (
<div className="min-w-0 flex-1">
<EntryContent entry={activeEntry} />
</div>
)}
</AnimatePresence>
<Outlet />
</div>
</HotkeysProvider>
)

View File

@ -0,0 +1 @@
export { Outlet as Component } from "react-router-dom"

View File

@ -0,0 +1,11 @@
import { levels, ROUTE_FEED_PENDING } from "@renderer/lib/constants"
import { FeedViewType } from "@renderer/lib/enum"
import { redirect } from "react-router-dom"
export function Component() {
return null
}
export const loader = () =>
// navigate to the first feed
redirect(`/feeds/${ROUTE_FEED_PENDING}?view=${FeedViewType.Articles}&level=${levels.view}`)

View File

@ -1,22 +1,17 @@
import { feedActions, useFeedStore } from "@renderer/store"
import { useEffect } from "react"
import { Outlet, useNavigate } from "react-router-dom"
import { setMainContainerElement } from "@renderer/atoms"
import { FeedColumn } from "@renderer/modules/feed-column"
import { Outlet } from "react-router-dom"
function MainLayout() {
const navigate = useNavigate()
const changed = useFeedStore(
(state) => `${state.activeList?.view}-${state.activeList?.id}`,
export function Component() {
return (
<div className="flex h-full">
<div className="w-64 shrink-0 border-r">
<FeedColumn />
</div>
{/* NOTE: tabIndex for main element can get by `document.activeElement` */}
<main ref={setMainContainerElement} className="flex min-w-0 flex-1 bg-theme-background !outline-none" tabIndex={-1}>
<Outlet />
</main>
</div>
)
useEffect(() => {
feedActions.setActiveEntry(null)
if (changed) {
navigate("/")
}
}, [changed])
return <Outlet />
}
export { MainLayout as Component }

View File

@ -0,0 +1,17 @@
import { setNavigate, setRoute } from "@renderer/atoms"
import { useLayoutEffect } from "react"
import { useNavigate, useParams, useSearchParams } from "react-router-dom"
export const BizRouterProvider = () => {
const [searchParams] = useSearchParams()
const params = useParams()
const nav = useNavigate()
useLayoutEffect(() => {
setRoute({
params,
searchParams,
})
setNavigate({ fn: nav })
}, [searchParams, params, nav])
return null
}

View File

@ -9,6 +9,8 @@ import { Provider } from "jotai"
import type { FC, PropsWithChildren } from "react"
import { HelmetProvider } from "react-helmet-async"
import { BizRouterProvider } from "./biz-router-provider"
const loadFeatures = () =>
import("../framer-lazy-feature").then((res) => res.default)
export const RootProviders: FC<PropsWithChildren> = ({ children }) => (
@ -28,6 +30,7 @@ export const RootProviders: FC<PropsWithChildren> = ({ children }) => (
<Provider store={jotaiStore}>
<ModalStackProvider />
<HelmetProvider>{children}</HelmetProvider>
<BizRouterProvider />
</Provider>
</TooltipProvider>
</PersistQueryClientProvider>

View File

@ -1,7 +1,7 @@
import { useBizInfiniteQuery, useBizQuery } from "@renderer/hooks"
import { apiClient } from "@renderer/lib/api-fetch"
import { defineQuery } from "@renderer/lib/defineQuery"
import { entryActions } from "@renderer/store/entry/entry"
import { entryActions } from "@renderer/store"
export const entries = {
entries: ({

View File

@ -5,7 +5,6 @@ import { buildGlobRoutes } from "./lib/route-builder"
const globTree = import.meta.glob("./pages/**/*.tsx")
const tree = buildGlobRoutes(globTree)
// console.log(tree)
export const router = createBrowserRouter([
{

View File

@ -4,13 +4,13 @@ import type { EntryModel } from "@renderer/models"
import { useShallow } from "zustand/react/shallow"
import { useFeedIdByView } from "../subscription"
import { useEntryStore } from "./entry"
import { useEntryStore } from "./store"
interface EntryFilter {
unread?: boolean
}
export const useEntry = (entryId: string | null): EntryModel | null =>
export const useEntry = (entryId: Nullable<string >): EntryModel | null =>
useEntryStore(useShallow((state) => entryId ? state.flatMapEntries[entryId] : null))
// feedId: single feedId, multiple feedId joint by `,`, and `collections`
export const useEntryIdsByFeedId = (feedId: string, filter?: EntryFilter) =>

View File

@ -1,3 +1,3 @@
export * from "./entry"
export * from "./hooks"
export * from "./store"
export * from "./types"

View File

@ -4,6 +4,7 @@ import type { EntryModel } from "@renderer/models"
import { produce } from "immer"
import { merge, omit } from "lodash-es"
import { feedActions } from "../feed"
import { unreadActions } from "../unread"
import { createZustandStore, getStoreActions } from "../utils/helper"
import type { EntryActions, EntryState } from "./types"
@ -132,6 +133,10 @@ export const useEntryStore = createZustandStore<EntryState & EntryActions>(
draft.flatMapEntries[entry.entries.id] || {},
entry,
)
const feeds = entries.map((entry) => entry.feeds)
// Insert to feed store
feedActions.upsertMany(feeds)
}
return draft
}),

View File

@ -1,38 +0,0 @@
import type { ActiveEntryId, ActiveList } from "@renderer/models"
import { create } from "zustand"
import { getStoreActions } from "./utils/helper"
interface FeedStoreActions {
setActiveList: (value: ActiveList) => void
setActiveEntry: (value: ActiveEntryId) => void
}
interface FeedStoreState {
activeList: ActiveList
activeEntryId: ActiveEntryId
}
type FeedStore = FeedStoreState & Readonly<FeedStoreActions>
export const useFeedStore = create<FeedStore>((set) => ({
activeList: {
level: "view",
id: 0,
name: "Articles",
view: 0,
},
activeEntryId: null,
activeEntryIndex: null,
// Actions
setActiveEntry: (value) => set({ activeEntryId: value }),
setActiveList: (value) => set({ activeList: value }),
}))
export const feedActions = getStoreActions(useFeedStore)
export const getCurrentFeedId = () => useFeedStore.getState().activeList.id
export const getCurrentEntryId = () => useFeedStore.getState().activeEntryId
/** Hooks */
export const useFeedActiveList = () =>
useFeedStore((state) => state.activeList)

View File

@ -0,0 +1,23 @@
import { useRouteParms } from "@renderer/hooks/biz/useRouteParams"
import { ROUTE_FEED_PENDING, views } from "@renderer/lib/constants"
import type { FeedModel } from "@renderer/models"
import { useShallow } from "zustand/react/shallow"
import { useFeedStore } from "./store"
export const useFeedById = (feedId: Nullable<string>): FeedModel | null =>
useFeedStore((state) => (feedId ? state.feeds[feedId] : null))
export const useFeedByIdSelector = <T>(
feedId: Nullable<string>,
selector: (feed: FeedModel) => T,
) => useFeedStore(useShallow((state) => (feedId && state.feeds[feedId] ? selector(state.feeds[feedId]) : null)))
export const useFeedHeaderTitle = () => {
const { feedId: currentFeedId, category, view } = useRouteParms()
const feedTitle = useFeedByIdSelector(currentFeedId, (feed) => feed.title)
return currentFeedId === ROUTE_FEED_PENDING ?
views[view].name :
category || feedTitle
}

View File

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

View File

@ -0,0 +1,40 @@
import type { FeedModel } from "@renderer/models"
import { produce } from "immer"
import { createZustandStore, getStoreActions } from "../utils/helper"
import type { FeedActions, FeedState } from "./types"
export const useFeedStore = createZustandStore<FeedState & FeedActions>(
"feed",
{
version: 1,
},
)((set) => ({
feeds: {},
clear() {
set({ feeds: {} })
},
upsertMany(feeds) {
set((state) =>
produce(state, (state) => {
for (const feed of feeds) {
if (feed.id) { state.feeds[feed.id] = feed }
}
}),
)
},
optimisticUpdate(feedId, changed) {
set((state) =>
produce(state, (state) => {
const feed = state.feeds[feedId]
if (!feed) return
Object.assign(feed, changed)
}),
)
},
}))
export const feedActions = getStoreActions(useFeedStore)
export const getFeedById = (feedId: string): Nullable<FeedModel> => useFeedStore.getState().feeds[feedId]

View File

@ -0,0 +1,13 @@
import type { FeedModel } from "@renderer/models"
type FeedId = string
export interface FeedState {
feeds: Record<FeedId, FeedModel>
}
export interface FeedActions {
upsertMany: (feeds: FeedModel[]) => void
optimisticUpdate: (feedId: FeedId, changed: Partial<FeedModel>) => void
clear: () => void
}

View File

@ -2,20 +2,23 @@ import { apiClient } from "@renderer/lib/api-fetch"
import { FeedViewType } from "@renderer/lib/enum"
import type { SubscriptionModel } from "@renderer/models"
import { produce } from "immer"
import { omit } from "lodash-es"
import { entryActions } from "./entry/entry"
import { entryActions } from "./entry/store"
import { feedActions } from "./feed"
import { unreadActions } from "./unread"
import { createZustandStore, getStoreActions } from "./utils/helper"
type FeedId = string
export type SubscriptionPlainModel = Omit<SubscriptionModel, "feeds">
interface SubscriptionState {
data: Record<FeedId, SubscriptionModel>
data: Record<FeedId, SubscriptionPlainModel>
dataIdByView: Record<FeedViewType, FeedId[]>
}
interface SubscriptionActions {
upsert: (feedId: FeedId, subscription: SubscriptionModel) => void
fetchByView: (view?: FeedViewType) => Promise<SubscriptionModel[]>
upsertMany: (subscription: SubscriptionPlainModel[]) => void
fetchByView: (view?: FeedViewType) => Promise<SubscriptionPlainModel[]>
markReadByView: (view?: FeedViewType) => void
internal_reset: () => void
clear: () => void
@ -66,23 +69,20 @@ export const useSubscriptionStore = createZustandStore<
}))
}
set((state) =>
produce(state, (state) => {
res.data.forEach((subscription) => {
state.data[subscription.feeds.id] = subscription
state.dataIdByView[subscription.view].push(subscription.feeds.id)
return state
})
}),
)
get().upsertMany(res.data)
feedActions.upsertMany(res.data.map((s) => s.feeds))
return res.data
},
upsert: (feedId, subscription) => {
upsertMany: (subscriptions) => {
set((state) =>
produce(state, (state) => {
state.data[feedId] = subscription
return state
subscriptions.forEach((subscription) => {
state.data[subscription.feedId] = omit(subscription, "feeds")
state.dataIdByView[subscription.view].push(subscription.feedId)
return state
})
}),
)
},

View File

@ -1,11 +1,12 @@
import { entryActions } from "../entry/entry"
import { entryActions } from "../entry/store"
import { feedActions } from "../feed"
import { subscriptionActions } from "../subscription"
import { uiActions } from "../ui"
import { unreadActions } from "../unread"
export const clearLocalPersistStoreData = () => {
// All clear and reset method will aggregate here
[entryActions, subscriptionActions, unreadActions, uiActions].forEach(
[entryActions, subscriptionActions, unreadActions, uiActions, feedActions].forEach(
(actions) => {
actions.clear()
},