(
setOpen(true)
props.onFocus?.(e)
}}
- value={value}
+ value={value || void 0}
onValueChange={(value) => {
setOpen(true)
onValueChange(value)
diff --git a/src/renderer/src/global.d.ts b/src/renderer/src/global.d.ts
new file mode 100644
index 000000000..d989c383f
--- /dev/null
+++ b/src/renderer/src/global.d.ts
@@ -0,0 +1,14 @@
+import type { FC, PropsWithChildren } from "react"
+
+declare global {
+
+ export type Component = FC
+
+ export type ComponentType = {
+ className?: string
+ } & PropsWithChildren &
+ P
+
+}
+
+export {}
diff --git a/src/renderer/src/hono.ts b/src/renderer/src/hono.ts
index fb75832d9..b52c82e77 100644
--- a/src/renderer/src/hono.ts
+++ b/src/renderer/src/hono.ts
@@ -518,7 +518,7 @@ declare const routes: hono_hono_base.HonoBase = T extends (...args: any[]) => infer R
+ ? R
+ : never
+
+export function useBizQuery<
+ TQuery extends DefinedQuery,
+ TError = FetchError | RequestError,
+ TQueryFnData = Awaited>,
+ TData = TQueryFnData,
+>(
+ query: TQuery,
+ options: Omit<
+ UseQueryOptions,
+ "queryKey" | "queryFn"
+ > = {},
+): UseQueryResult {
+ // @ts-expect-error
+ return useQuery({
+ queryKey: query.key,
+ queryFn: query.fn,
+ ...options,
+ })
+}
+
+export function useBizInfiniteQuery<
+ T extends DefinedQuery,
+ E = FetchError | RequestError,
+ FNR = Awaited>,
+ R = FNR,
+>(
+ query: T,
+ options: Omit, "queryKey" | "queryFn">,
+): UseInfiniteQueryResult, FetchError | RequestError> {
+ // @ts-expect-error
+ return useInfiniteQuery({
+ // @ts-expect-error
+ queryFn: query.fn,
+ queryKey: query.key,
+ ...options,
+ })
+}
diff --git a/src/renderer/src/hooks/useUpdateEntry.tsx b/src/renderer/src/hooks/useUpdateEntry.tsx
index 47f3322f9..c903dae93 100644
--- a/src/renderer/src/hooks/useUpdateEntry.tsx
+++ b/src/renderer/src/hooks/useUpdateEntry.tsx
@@ -1,49 +1,36 @@
import type { EntriesResponse, ListResponse } from "@renderer/lib/types"
+import { Queries } from "@renderer/queries"
import type { InfiniteData, QueryKey } from "@tanstack/react-query"
-import {
- useQueryClient,
-} from "@tanstack/react-query"
+import { useQueryClient } from "@tanstack/react-query"
-export const useUpdateEntry = ({
- entryId,
-}: {
- entryId?: string
-}) => {
+export const useUpdateEntry = ({ entryId }: { entryId?: string }) => {
const queryClient = useQueryClient()
- const updateEntry = (
- changed: Partial,
- ) => {
- const key = ["entry", entryId]
- const data = queryClient.getQueryData(key)
- if (data) {
- queryClient.setQueryData(
- key,
- Object.assign({}, data, changed),
- )
- }
+ 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 list = (data as InfiniteData>)?.pages?.[0]?.data
- if (list) {
- for (const item of list) {
- if (item.id === entryId) {
- for (const [key, value] of Object.entries(changed)) {
- item[key] = value
- }
- queryClient.setQueryData(key, data)
+ entriesData.forEach(([key, data]: [QueryKey, unknown]) => {
+ const assertData = data as InfiniteData>
+ const list = assertData?.pages?.[0]?.data
+ if (list) {
+ for (const item of list) {
+ if (item.id === entryId) {
+ for (const [key, value] of Object.entries(changed)) {
+ item[key] = value
}
+ queryClient.setQueryData(key, assertData)
}
}
- },
- )
+ }
+ })
}
return updateEntry
diff --git a/src/renderer/src/lib/defineQuery.ts b/src/renderer/src/lib/defineQuery.ts
new file mode 100644
index 000000000..7d1f8e467
--- /dev/null
+++ b/src/renderer/src/lib/defineQuery.ts
@@ -0,0 +1,188 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+import type {
+ InfiniteData,
+ QueryFunction,
+ QueryKey,
+} from "@tanstack/react-query"
+import type { Draft, nothing } from "immer"
+import { produce } from "immer"
+
+import { queryClient } from "./query-client"
+
+// From immer, but immer not export this type
+type ValidRecipeReturnType =
+ | State
+ | void
+ | undefined
+ | (State extends undefined ? typeof nothing : never)
+
+export type DefinedQuery = Readonly<{
+ key: TQueryKey
+ fn: QueryFunction
+ rootKey?: QueryKey
+
+ cancel: (key?: (key: TQueryKey) => QueryKey) => Promise
+ remove: (key?: (key: TQueryKey) => QueryKey) => Promise
+
+ invalidate: (key?: (key: TQueryKey) => QueryKey) => Promise
+ invalidateRoot: () => void
+
+ refetch: () => Promise
+ prefetch: () => Promise
+
+ setData: (
+ updater: (draft: Draft) => ValidRecipeReturnType>
+ ) => void
+ setInfiniteData: (
+ updater: (
+ draft: Draft>
+ ) => ValidRecipeReturnType>>
+ ) => void
+ getData: () => TData | undefined
+
+ optimisticUpdate: (
+ updater: (draft: Draft) => ValidRecipeReturnType> | void
+ ) => Promise<{
+ previousData: Awaited | undefined
+ restore: () => void
+ invalidate: () => void
+ }>
+
+ optimisticInfiniteUpdate: (
+ updater: (
+ draft: Draft>
+ ) => ValidRecipeReturnType>>
+ ) => Promise<{
+ previousData: Awaited> | undefined
+ restore: () => void
+ invalidate: () => void
+ }>
+}>
+
+export type DefinedQueryOptions = {
+ // shouldPersist?: boolean;
+ rootKey?: QueryKey
+
+ onCancel?: () => void | Promise
+ onInvalidate?: () => void | Promise
+ onInvalidateRoot?: () => void
+ onRefetch?: (data?: TData) => void
+ onRefetchRoot?: () => void
+ onOptimisticUpdate?: () => void
+ onOptimisticUpdateRestore?: () => void
+}
+
+export function defineQuery<
+ TQueryKey extends QueryKey,
+ TQueryFn extends QueryFunction,
+ TData = Awaited>,
+>(
+ key: TQueryKey,
+ fn: TQueryFn,
+ options?: DefinedQueryOptions
+): DefinedQuery
+
+export function defineQuery<
+ TQueryKey extends QueryKey,
+ TQueryFn extends QueryFunction,
+ TData = Awaited>,
+>(key: TQueryKey, fn: TQueryFn, options?: DefinedQueryOptions) {
+ const queryDefine: DefinedQuery = {
+ key,
+ fn,
+ rootKey: options?.rootKey,
+
+ invalidateRoot: () => {
+ if (options?.rootKey) {
+ queryClient.invalidateQueries({
+ queryKey: options.rootKey,
+ refetchType: "all",
+ })
+ options?.onInvalidateRoot?.()
+ }
+ },
+ prefetch: async () => {
+ await queryClient.prefetchQuery({
+ queryKey: key,
+ queryFn: fn,
+ })
+ },
+ cancel: async (keyExtactor) => {
+ const queryKey =
+ typeof keyExtactor === "function" ? keyExtactor(key) : key
+ await queryClient.cancelQueries({
+ queryKey,
+ })
+ options?.onCancel?.()
+ },
+ remove: async (keyExtactor) => {
+ const queryKey =
+ typeof keyExtactor === "function" ? keyExtactor(key) : key
+ queryClient.removeQueries({ queryKey })
+ },
+ invalidate: async (keyExtactor) => {
+ const queryKey =
+ typeof keyExtactor === "function" ? keyExtactor(key) : key
+
+ await queryClient.invalidateQueries({
+ queryKey,
+ refetchType: "all",
+ })
+ options?.onInvalidate?.()
+ },
+
+ refetch: async () => {
+ await queryClient.refetchQueries({
+ queryKey: key,
+ })
+ options?.onRefetch?.()
+ return queryClient.getQueryData(key)
+ },
+
+ setData: (updater) =>
+ queryClient.setQueryData(key, (old) => {
+ if (!old) return
+ if (typeof updater !== "function") return old
+
+ return produce(old, updater)
+ }),
+
+ setInfiniteData: (updater) =>
+ queryDefine.setData>(updater),
+ getData: () => queryClient.getQueryData(key),
+
+ optimisticUpdate: async (
+ updater: (draft: Draft) => ValidRecipeReturnType>,
+ ) => {
+ await queryClient.cancelQueries({
+ queryKey: key,
+ })
+ const previousData = await queryClient.getQueryData(key)
+ await queryClient.setQueryData(key, (old) => {
+ if (!old) return
+ if (typeof updater !== "function") return old
+
+ return produce(old, updater)
+ })
+ options?.onOptimisticUpdate?.()
+
+ return {
+ previousData,
+ restore: () => {
+ queryClient.setQueryData(key, previousData)
+ options?.onOptimisticUpdateRestore?.()
+ },
+ invalidate: () => {
+ queryClient.invalidateQueries({ queryKey: key, refetchType: "all" })
+ options?.onInvalidate?.()
+ },
+ }
+ },
+
+ optimisticInfiniteUpdate(updater) {
+ return queryDefine.optimisticUpdate>(updater)
+ },
+ }
+
+ return Object.freeze(queryDefine)
+}
diff --git a/src/renderer/src/lib/parse-html.ts b/src/renderer/src/lib/parse-html.ts
index 28f56764b..3af8568db 100644
--- a/src/renderer/src/lib/parse-html.ts
+++ b/src/renderer/src/lib/parse-html.ts
@@ -44,8 +44,10 @@ export const parseHtml = async (content: string) => {
content: toJsxRuntime(hastTree, {
Fragment,
ignoreInvalidStyle: true,
- jsx,
- jsxs,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ jsx: (type, props, key) => jsx(type as any, props, key),
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ jsxs: (type, props, key) => jsxs(type as any, props, key),
passNode: true,
}),
}
diff --git a/src/renderer/src/lib/types.ts b/src/renderer/src/lib/types.ts
index b82271b4e..8e6c28173 100644
--- a/src/renderer/src/lib/types.ts
+++ b/src/renderer/src/lib/types.ts
@@ -9,22 +9,7 @@ export type ActiveList = {
preventNavigate?: boolean
} | null
-export type FeedResponse = {
- id: string
- url: string
- title?: string
- description?: string
- siteUrl?: string
- image?: string
- checkedAt: string
- nextCheckAt: string
- lastModifiedHeader?: string
- etagHeader?: string
- ttl: number
- errorAt?: string
- errorMessage?: string
-}
-
+export type FeedResponse = SubscriptionResponse[number]["feeds"]
export type SubscriptionResponse = Array<
Exclude<
Extract<
@@ -35,31 +20,40 @@ export type SubscriptionResponse = Array<
>[number] & { unread?: number }
>
-export type EntriesResponse = {
- author?: string
- category?: string[]
- changedAt: string
- content?: string
- description?: string
- enclosure?: {
- url: string
- type?: string
- length?: number
- title?: string
- }
- feedId: string
- guid: string
- id: string
- images?: string[]
- publishedAt: string
- readingTime?: number
- title?: string
- url?: string
+// export type EntriesResponse = {
+// author?: string
+// category?: string[]
+// changedAt: string
+// content?: string
+// description?: string
+// enclosure?: {
+// url: string
+// type?: string
+// length?: number
+// title?: string
+// }
+// feedId: string
+// guid: string
+// id: string
+// images?: string[]
+// publishedAt: string
+// readingTime?: number
+// title?: string
+// url?: string
- feeds: FeedResponse
- collected: boolean
- read: boolean
-}[]
+// feeds: FeedResponse
+// collected: boolean
+// read: boolean
+// }[]
+export type EntriesResponse = Array<
+ Exclude<
+ Extract<
+ InferResponseType,
+ { code: 0 }
+ >["data"],
+ undefined
+ >
+>
export type ListResponse = {
code: number
diff --git a/src/renderer/src/pages/(main)/layout.tsx b/src/renderer/src/pages/(main)/layout.tsx
index 06aad7ac4..97bd38226 100644
--- a/src/renderer/src/pages/(main)/layout.tsx
+++ b/src/renderer/src/pages/(main)/layout.tsx
@@ -18,7 +18,8 @@ export function Component() {
}, [navigate, preventNavigate])
if (status !== "authenticated") {
- return navigate("/login")
+ navigate("/login")
+ return null
}
return
diff --git a/src/renderer/src/pages/login.tsx b/src/renderer/src/pages/login.tsx
index 0b04b82a8..289fe5131 100644
--- a/src/renderer/src/pages/login.tsx
+++ b/src/renderer/src/pages/login.tsx
@@ -22,7 +22,8 @@ export function Component() {
}, [])
if (status === "authenticated") {
- return navigate("/redirect?app=follow")
+ navigate("/redirect?app=follow")
+ return null
}
return (
diff --git a/src/renderer/src/queries/entries.ts b/src/renderer/src/queries/entries.ts
index b1c95a9c3..4fa450b13 100644
--- a/src/renderer/src/queries/entries.ts
+++ b/src/renderer/src/queries/entries.ts
@@ -1,7 +1,69 @@
+import {
+ UnAuthorizedError,
+ UnprocessableEntityError,
+} from "@renderer/biz/error"
+import { useBizInfiniteQuery } from "@renderer/hooks/useBizQuery"
import { levels } from "@renderer/lib/constants"
-import type { DataResponse, EntriesResponse, ListResponse } from "@renderer/lib/types"
-import { apiFetch } from "@renderer/queries/api-fetch"
-import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
+import { defineQuery } from "@renderer/lib/defineQuery"
+import type { EntriesResponse, ListResponse } from "@renderer/lib/types"
+import { apiClient, apiFetch } from "@renderer/queries/api-fetch"
+
+export const entries = {
+ entries: ({
+ level,
+ id,
+ view,
+ }: {
+ level?: string
+ id?: number | string
+ view?: number
+ }) =>
+ defineQuery(
+ ["entries", level, id],
+ async ({ pageParam }) => {
+ const params: {
+ category?: string
+ view?: number
+ feedId?: string
+ feedIdList?: string[]
+ } = {}
+ if (level === levels.folder) {
+ params.feedIdList = `${id}`.split(",")
+ } else if (level === levels.feed) {
+ params.feedId = `${id}`
+ }
+ return await apiFetch>("/entries", {
+ method: "POST",
+ body: {
+ offset: pageParam,
+ view,
+ ...params,
+ },
+ })
+ },
+ {
+ rootKey: ["entries"],
+ },
+ ),
+ byId: (id?: string | null) =>
+ defineQuery(
+ ["entry", id],
+ async () => {
+ if (!id) {
+ throw new UnprocessableEntityError("id is required")
+ }
+ const res = await apiClient.entries.$get({ query: { id } })
+ const json = await res.json()
+ if (json.code === 1) {
+ throw new UnAuthorizedError()
+ }
+ return json.data
+ },
+ {
+ rootKey: ["entries"],
+ },
+ ),
+}
export const useEntries = ({
level,
@@ -12,48 +74,9 @@ export const useEntries = ({
id?: number | string
view?: number
}) =>
- useInfiniteQuery({
- queryKey: ["entries", level, id],
+ useBizInfiniteQuery(entries.entries({ level, id, view }), {
enabled: level !== undefined && id !== undefined,
- queryFn: async ({ pageParam }) => {
- const params: {
- category?: string
- view?: number
- feedId?: string
- feedIdList?: string[]
- } = {}
- if (level === levels.folder) {
- params.feedIdList = (`${id}`).split(",")
- } else if (level === levels.feed) {
- params.feedId = `${id}`
- }
- return await apiFetch>("/entries", {
- method: "POST",
- body: {
- offset: pageParam,
- view,
- ...params,
- },
- })
- },
getNextPageParam: (lastPage, _, lastPageParam) =>
- lastPageParam + (lastPage.data?.length || 0),
+ (lastPageParam as number) + (lastPage.data?.length || 0),
initialPageParam: 0,
})
-
-export const useEntry = ({ id }: { id?: string | null }) =>
- useQuery({
- queryKey: ["entry", id],
- enabled: !!id,
- queryFn: async () => {
- const data = await apiFetch>(
- "/entries",
- {
- query: {
- id,
- },
- },
- )
- return data.data
- },
- })
diff --git a/src/renderer/src/queries/index.ts b/src/renderer/src/queries/index.ts
new file mode 100644
index 000000000..258313d6f
--- /dev/null
+++ b/src/renderer/src/queries/index.ts
@@ -0,0 +1,7 @@
+import { entries } from "./entries"
+import { subscription } from "./subscriptions"
+
+export const Queries = {
+ subscription,
+ entries,
+}
diff --git a/src/renderer/src/queries/subscriptions.ts b/src/renderer/src/queries/subscriptions.ts
index b1d615f4c..d5aca1c0d 100644
--- a/src/renderer/src/queries/subscriptions.ts
+++ b/src/renderer/src/queries/subscriptions.ts
@@ -1,6 +1,7 @@
+import { UnAuthorizedError } from "@renderer/biz/error"
+import { defineQuery } from "@renderer/lib/defineQuery"
import type { SubscriptionResponse } from "@renderer/lib/types"
-import { apiClient, apiFetch } from "@renderer/queries/api-fetch"
-import { useQuery } from "@tanstack/react-query"
+import { apiClient } from "@renderer/queries/api-fetch"
import { parse } from "tldts"
export type Response = {
@@ -12,16 +13,16 @@ export type Response = {
unread: number
}
-export const useSubscriptions = (view?: number) =>
- useQuery({
- queryKey: ["subscriptions", view],
- queryFn: async () => {
- const res = await (await apiClient.subscriptions.$get({ query: { view: String(view) } })).json()
+export const subscription = {
+ byView: (view?: number) =>
+ defineQuery(["subscriptions", view], async () => {
+ const res = await (
+ await apiClient.subscriptions.$get({ query: { view: String(view) } })
+ ).json()
if (res.code === 1) {
- throw new Error(res.error)
+ throw new UnAuthorizedError()
}
const subscriptions = res.data as SubscriptionResponse
-
const categories = {
list: {},
unread: 0,
@@ -56,7 +57,7 @@ export const useSubscriptions = (view?: number) =>
const { domain } = parse(subscription.feeds.siteUrl)
if (domain && domains[domain] > 1) {
subscription.category =
- domain.slice(0, 1).toUpperCase() + domain.slice(1)
+ domain.slice(0, 1).toUpperCase() + domain.slice(1)
}
}
if (!subscription.category) {
@@ -88,22 +89,19 @@ export const useSubscriptions = (view?: number) =>
list,
unread: categories.unread,
} as Response
- },
- })
-
-export const useSubscriptionCategories = (view?: number) =>
- useQuery({
- queryKey: ["subscription-categories", view],
- queryFn: async () => {
- const { data: categories } = await apiFetch<{
- code: number
- data: string[]
- }>("/categories", {
- query: {
- view,
- },
- })
-
- return categories || []
- },
- })
+ }, {
+ rootKey: ["subscriptions"],
+ }),
+ categories: (view?: number) =>
+ defineQuery(["subscription-categories", view], async () => {
+ const res = await (
+ await apiClient.categories.$get({
+ query: { view: String(view) },
+ })
+ ).json()
+ if (res.code === 1) {
+ throw new UnAuthorizedError()
+ }
+ return res.data
+ }),
+}
diff --git a/src/renderer/src/shim.d.ts b/src/renderer/src/shim.d.ts
new file mode 100644
index 000000000..5391ffe55
--- /dev/null
+++ b/src/renderer/src/shim.d.ts
@@ -0,0 +1,6 @@
+///
+
+declare module "*?asset" {
+ const src: string
+ export default src
+}