refactor: query define for type safe and fix type error (#18)

* refactor: query

Signed-off-by: Innei <i@innei.in>

* update

Signed-off-by: Innei <i@innei.in>

* fix: type

Signed-off-by: Innei <i@innei.in>

---------

Signed-off-by: Innei <i@innei.in>
This commit is contained in:
Innei 2024-05-27 19:33:53 +08:00 committed by GitHub
parent a16c493ccd
commit 91e80d4dcc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 584 additions and 239 deletions

36
.github/workflows/lint.yml vendored Normal file
View File

@ -0,0 +1,36 @@
on:
push:
branches: [main]
name: CI Typecheck and Lint
jobs:
build:
name: Lint and Typecheck
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x]
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
lfs: true
- name: Checkout LFS objects
run: git lfs checkout
- uses: pnpm/action-setup@v4.0.0
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Lint and Typecheck
run: |
pnpm run typecheck && npm run lint

View File

@ -54,6 +54,7 @@
"electron-updater": "^6.1.7",
"framer-motion": "11.2.6",
"hast-util-to-jsx-runtime": "2.3.0",
"immer": "10.1.1",
"jotai": "2.8.1",
"jotai-dark": "^0.3.0",
"jotai-effect": "^1.0.0",

View File

@ -101,6 +101,9 @@ importers:
hast-util-to-jsx-runtime:
specifier: 2.3.0
version: 2.3.0
immer:
specifier: 10.1.1
version: 10.1.1
jotai:
specifier: 2.8.1
version: 2.8.1(@types/react@18.3.3)(react@18.3.1)
@ -160,7 +163,7 @@ importers:
version: 3.23.8
zustand:
specifier: 4.5.2
version: 4.5.2(@types/react@18.3.3)(react@18.3.1)
version: 4.5.2(@types/react@18.3.3)(immer@10.1.1)(react@18.3.1)
devDependencies:
'@egoist/tailwindcss-icons':
specifier: 1.8.0
@ -3094,6 +3097,7 @@ packages:
glob@8.1.0:
resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==}
engines: {node: '>=12'}
deprecated: Glob versions prior to v9 are no longer supported
global-agent@3.0.0:
resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
@ -3283,6 +3287,9 @@ packages:
resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==}
engines: {node: '>= 4'}
immer@10.1.1:
resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==}
import-fresh@3.3.0:
resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==}
engines: {node: '>=6'}
@ -4418,6 +4425,7 @@ packages:
rimraf@3.0.2:
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
roarr@2.15.4:
@ -8767,6 +8775,8 @@ snapshots:
ignore@5.3.1: {}
immer@10.1.1: {}
import-fresh@3.3.0:
dependencies:
parent-module: 1.0.1
@ -10602,11 +10612,12 @@ snapshots:
zod@3.23.8: {}
zustand@4.5.2(@types/react@18.3.3)(react@18.3.1):
zustand@4.5.2(@types/react@18.3.3)(immer@10.1.1)(react@18.3.1):
dependencies:
use-sync-external-store: 1.2.0(react@18.3.1)
optionalDependencies:
'@types/react': 18.3.3
immer: 10.1.1
react: 18.3.1
zwitch@2.0.4: {}

View File

@ -0,0 +1,18 @@
export abstract class RequestError extends Error {
name = "RequestError"
}
export class UnAuthorizedError extends RequestError {
name = "UnAuthorizedError"
constructor(message?: string) {
super(message)
}
}
export class UnprocessableEntityError extends RequestError {
name = "UnprocessableEntityError"
constructor(message?: string) {
super(message)
}
}

View File

@ -1,8 +1,10 @@
import type { ReactNode } from "react"
import { useState } from "react"
function Versions(): JSX.Element {
const [versions] = useState(window.electron.process.versions)
function Versions(): ReactNode {
const [versions] = useState(window.electron?.process.versions)
if (!versions) return null
return (
<ul className="versions">
<li className="electron-version">

View File

@ -70,7 +70,7 @@ export function EntryItemWrapper({
e.stopPropagation()
feedActions.setActiveEntry(entry.id)
}}
onDoubleClick={() => window.open(entry.url, "_blank")}
onDoubleClick={() => entry.url && window.open(entry.url, "_blank")}
onContextMenu={(e) => {
e.preventDefault()
showNativeMenu(

View File

@ -1,15 +1,14 @@
import { useBizQuery } from "@renderer/hooks/useBizQuery"
import { parseHtml } from "@renderer/lib/parse-html"
import type { ActiveEntry } from "@renderer/lib/types"
import { useEntry } from "@renderer/queries/entries"
import { Queries } from "@renderer/queries"
import { m } from "framer-motion"
import { useEffect, useState } from "react"
import { EntryShare } from "./share"
export function EntryContent({ entryId }: { entryId: ActiveEntry }) {
const entry = useEntry({
id: entryId,
})
const entry = useBizQuery(Queries.entries.byId(entryId))
const [content, setContent] = useState<JSX.Element>()
@ -23,6 +22,8 @@ export function EntryContent({ entryId }: { entryId: ActiveEntry }) {
}
}, [entry.data?.content])
if (!entry.data) return null
return (
<>
<EntryShare entry={entry.data} view={0} />
@ -35,7 +36,7 @@ export function EntryContent({ entryId }: { entryId: ActiveEntry }) {
>
<div>
<a
href={entry.data?.url}
href={entry.data?.url || void 0}
target="_blank"
className="mx-auto block max-w-[598px] rounded-md p-6 transition-colors hover:bg-zinc-100"
rel="noreferrer"

View File

@ -7,8 +7,9 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@renderer/components/ui/alert-dialog"
import { Queries } from "@renderer/queries"
import { apiFetch } from "@renderer/queries/api-fetch"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useMutation } from "@tanstack/react-query"
export function CategoryRemoveDialog({
feedIdList,
@ -21,7 +22,6 @@ export function CategoryRemoveDialog({
category: string
view?: number
}) {
const queryClient = useQueryClient()
const renameMutation = useMutation({
mutationFn: async () =>
apiFetch("/categories", {
@ -32,9 +32,8 @@ export function CategoryRemoveDialog({
},
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["subscriptions", view],
})
Queries.subscription.byView(view).invalidate()
onSuccess?.()
},
})

View File

@ -13,8 +13,9 @@ import {
FormMessage,
} from "@renderer/components/ui/form"
import { Input } from "@renderer/components/ui/input"
import { Queries } from "@renderer/queries"
import { apiFetch } from "@renderer/queries/api-fetch"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useMutation } from "@tanstack/react-query"
import { useForm } from "react-hook-form"
import { z } from "zod"
@ -40,7 +41,6 @@ export function CategoryRenameDialog({
},
})
const queryClient = useQueryClient()
const renameMutation = useMutation({
mutationFn: async (values: z.infer<typeof formSchema>) =>
apiFetch("/categories", {
@ -51,9 +51,8 @@ export function CategoryRenameDialog({
},
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["subscriptions", view],
})
Queries.subscription.byView(view).invalidate()
onSuccess?.()
},
})

View File

@ -7,10 +7,11 @@ 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 { 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 { useMutation, useQueryClient } from "@tanstack/react-query"
import { useMutation } from "@tanstack/react-query"
import { AnimatePresence, m } from "framer-motion"
import { useEffect, useState } from "react"
@ -33,7 +34,6 @@ export function FeedCategory({
const [open, setOpen] = useState(!data.name)
const [dialogOpen, setDialogOpen] = useState(false)
const queryClient = useQueryClient()
const feedIdList = data.list.map((feed) => feed.feedId)
const deleteMutation = useMutation({
mutationFn: async () =>
@ -45,9 +45,7 @@ export function FeedCategory({
},
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["subscriptions", view],
})
Queries.subscription.byView(view).invalidate()
},
})

View File

@ -15,9 +15,10 @@ 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 { Queries } from "@renderer/queries"
import { apiFetch } from "@renderer/queries/api-fetch"
import { feedActions, useFeedActiveList } from "@renderer/store"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useMutation } from "@tanstack/react-query"
import { useState } from "react"
export function FeedItem({
@ -45,7 +46,6 @@ export function FeedItem({
const { toast } = useToast()
const queryClient = useQueryClient()
const deleteMutation = useMutation({
mutationFn: async (feed: SubscriptionResponse[number]) =>
apiFetch("/subscriptions", {
@ -55,9 +55,8 @@ export function FeedItem({
},
}),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: ["subscriptions", feed.view],
})
Queries.subscription.byView(variables.view).invalidate()
toast({
duration: 3000,
description: (
@ -82,9 +81,8 @@ export function FeedItem({
isPrivate: variables.isPrivate,
},
})
queryClient.invalidateQueries({
queryKey: ["subscriptions", feed.view],
})
Queries.subscription.byView(feed.view).invalidate()
}}
>
Undo
@ -142,7 +140,7 @@ export function FeedItem({
{
type: "text",
label: "Open Site in Browser",
click: () => window.open(feed.feeds.siteUrl, "_blank"),
click: () => feed.feeds.siteUrl && window.open(feed.feeds.siteUrl, "_blank"),
},
],
e,

View File

@ -1,6 +1,7 @@
import { useBizQuery } from "@renderer/hooks/useBizQuery"
import { levels, views } from "@renderer/lib/constants"
import { cn } from "@renderer/lib/utils"
import { useSubscriptions } from "@renderer/queries/subscriptions"
import { Queries } from "@renderer/queries"
import { feedActions } from "@renderer/store"
import { useState } from "react"
@ -15,7 +16,7 @@ export function FeedList({
view?: number
hideTitle?: boolean
}) {
const subscriptions = useSubscriptions(view)
const subscriptions = useBizQuery(Queries.subscription.byView(view))
const [expansion, setExpansion] = useState(false)
const { setActiveList } = feedActions

View File

@ -11,7 +11,7 @@ export function FollowSummary({
return (
<div className="max-w-[462px] select-text space-y-1 text-sm">
<a
href={feed.siteUrl}
href={feed.siteUrl || void 0}
target="_blank"
className="flex items-center"
rel="noreferrer"

View File

@ -23,12 +23,13 @@ import {
SelectValue,
} from "@renderer/components/ui/select"
import { Switch } from "@renderer/components/ui/switch"
import { useBizQuery } from "@renderer/hooks/useBizQuery"
import { views } from "@renderer/lib/constants"
import type { SubscriptionResponse } from "@renderer/lib/types"
import { cn } from "@renderer/lib/utils"
import { Queries } from "@renderer/queries"
import { apiFetch } from "@renderer/queries/api-fetch"
import { useSubscriptionCategories } from "@renderer/queries/subscriptions"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useMutation } from "@tanstack/react-query"
import { useForm } from "react-hook-form"
import { z } from "zod"
@ -36,7 +37,7 @@ import { FollowSummary } from "../feed-summary"
const formSchema = z.object({
view: z.string(),
category: z.string().optional(),
category: z.string().nullable().optional(),
isPrivate: z.boolean().optional(),
})
@ -68,7 +69,6 @@ export function FollowDialog({
},
})
const queryClient = useQueryClient()
const followMutation = useMutation({
mutationFn: async (values: z.infer<typeof formSchema>) =>
apiFetch("/subscriptions", {
@ -83,13 +83,10 @@ export function FollowDialog({
}),
onSuccess: (_, variables) => {
if (isSubscribed && variables.view !== `${feed.view}`) {
queryClient.invalidateQueries({
queryKey: ["subscriptions", feed.view],
})
Queries.subscription.byView(feed.view).invalidate()
}
queryClient.invalidateQueries({
queryKey: ["subscriptions", Number.parseInt(variables.view)],
})
Queries.subscription.byView(Number.parseInt(variables.view)).invalidate()
onSuccess?.(variables)
},
})
@ -98,7 +95,9 @@ export function FollowDialog({
followMutation.mutate(values)
}
const categories = useSubscriptionCategories(Number.parseInt(form.watch("view")))
const categories = useBizQuery(
Queries.subscription.categories(Number.parseInt(form.watch("view"))),
)
return (
<DialogContent>
@ -153,8 +152,8 @@ export function FollowDialog({
</div>
<FormControl>
<AutoComplete
options={categories.data}
emptyMessage="No resulsts."
options={categories.data || []}
emptyMessage="No results."
{...field}
/>
</FormControl>

View File

@ -30,10 +30,13 @@ const formSchema = z.object({
keyword: z.string().min(1),
})
const info: Record<string, {
label: string
prefix?: string
}> = {
const info: Record<
string,
{
label: string
prefix?: string
}
> = {
general: {
label: "Any URL or Keyword",
},
@ -150,31 +153,34 @@ export function FollowForm({ type }: { type: string }) {
<div className="grid grid-cols-4 gap-4">
{item.entries
.filter((e) => !!e)
.map((entry) => (
<a
key={entry.id}
href={entry.url}
target="_blank"
className="flex min-w-0 flex-1 flex-col items-center gap-1"
rel="noreferrer"
>
{entry.images?.[0] ?
(
<Image
src={entry.images?.[0]}
className="aspect-square w-full"
/>
) :
(
<div className="flex aspect-square w-full overflow-hidden rounded bg-stone-100 p-2 text-xs leading-tight text-zinc-500">
{entry.title}
</div>
)}
<div className="line-clamp-2 w-full text-xs leading-tight">
{entry.title}
</div>
</a>
))}
.map((entry) => {
const assertEntry = entry as EntriesResponse[number]
return (
<a
key={assertEntry.id}
href={assertEntry.url || void 0}
target="_blank"
className="flex min-w-0 flex-1 flex-col items-center gap-1"
rel="noreferrer"
>
{assertEntry.images?.[0] ?
(
<Image
src={assertEntry.images?.[0]}
className="aspect-square w-full"
/>
) :
(
<div className="flex aspect-square w-full overflow-hidden rounded bg-stone-100 p-2 text-xs leading-tight text-zinc-500">
{assertEntry.title}
</div>
)}
<div className="line-clamp-2 w-full text-xs leading-tight">
{assertEntry.title}
</div>
</a>
)
})}
</div>
)}
</CardContent>

View File

@ -12,8 +12,9 @@ import {
import { Input } from "@renderer/components/ui/input"
import type { FeedResponse } from "@renderer/lib/types"
import { cn } from "@renderer/lib/utils"
import { Queries } from "@renderer/queries"
import { apiFetch } from "@renderer/queries/api-fetch"
import { useMutation, useQueryClient } from "@tanstack/react-query"
import { useMutation } from "@tanstack/react-query"
import { useForm } from "react-hook-form"
import { z } from "zod"
@ -54,7 +55,6 @@ export function FollowImport() {
resolver: zodResolver(formSchema),
})
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: async (file: File) => {
const formData = new FormData()
@ -72,9 +72,7 @@ export function FollowImport() {
return data
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["subscriptions"],
})
Queries.subscription.byView().invalidateRoot()
},
})

View File

@ -10,10 +10,11 @@ import { useCallback, useState } from "react"
import * as React from "react"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "value"> {
// TODO isLoading
options: string[]
emptyMessage: string
value?: string
value?: string | null
}
export const AutoComplete = React.forwardRef<HTMLInputElement, InputProps>(
@ -51,7 +52,7 @@ export const AutoComplete = React.forwardRef<HTMLInputElement, InputProps>(
setOpen(true)
props.onFocus?.(e)
}}
value={value}
value={value || void 0}
onValueChange={(value) => {
setOpen(true)
onValueChange(value)

14
src/renderer/src/global.d.ts vendored Normal file
View File

@ -0,0 +1,14 @@
import type { FC, PropsWithChildren } from "react"
declare global {
export type Component<P = object> = FC<ComponentType & P>
export type ComponentType<P = object> = {
className?: string
} & PropsWithChildren &
P
}
export {}

View File

@ -518,7 +518,7 @@ declare const routes: hono_hono_base.HonoBase<hono.Env, {
author: string | null;
changedAt: string;
publishedAt: string;
images: string | null;
images: string[] | null;
categories: string | null;
collected: boolean;
read: boolean;

View File

@ -0,0 +1,56 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { RequestError } from "@renderer/biz/error"
import type { DefinedQuery } from "@renderer/lib/defineQuery"
import type {
InfiniteData,
QueryKey,
UseInfiniteQueryOptions,
UseInfiniteQueryResult,
UseQueryOptions,
UseQueryResult,
} from "@tanstack/react-query"
import { useInfiniteQuery, useQuery } from "@tanstack/react-query"
import type { FetchError } from "ofetch"
// TODO split normal define query and infinite define query for better type checking
export type SafeReturnType<T> = T extends (...args: any[]) => infer R
? R
: never
export function useBizQuery<
TQuery extends DefinedQuery<QueryKey, any>,
TError = FetchError | RequestError,
TQueryFnData = Awaited<ReturnType<TQuery["fn"]>>,
TData = TQueryFnData,
>(
query: TQuery,
options: Omit<
UseQueryOptions<TQueryFnData, TError>,
"queryKey" | "queryFn"
> = {},
): UseQueryResult<TData, FetchError> {
// @ts-expect-error
return useQuery({
queryKey: query.key,
queryFn: query.fn,
...options,
})
}
export function useBizInfiniteQuery<
T extends DefinedQuery<any, any>,
E = FetchError | RequestError,
FNR = Awaited<ReturnType<T["fn"]>>,
R = FNR,
>(
query: T,
options: Omit<UseInfiniteQueryOptions<FNR, E>, "queryKey" | "queryFn">,
): UseInfiniteQueryResult<InfiniteData<R>, FetchError | RequestError> {
// @ts-expect-error
return useInfiniteQuery<T, E>({
// @ts-expect-error
queryFn: query.fn,
queryKey: query.key,
...options,
})
}

View File

@ -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<EntriesResponse[number]>,
) => {
const key = ["entry", entryId]
const data = queryClient.getQueryData(key)
if (data) {
queryClient.setQueryData(
key,
Object.assign({}, data, changed),
)
}
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 list = (data as InfiniteData<ListResponse<EntriesResponse>>)?.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<ListResponse<EntriesResponse>>
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<typeof assertData>(key, assertData)
}
}
},
)
}
})
}
return updateEntry

View File

@ -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> =
| State
| void
| undefined
| (State extends undefined ? typeof nothing : never)
export type DefinedQuery<TQueryKey extends QueryKey, TData> = Readonly<{
key: TQueryKey
fn: QueryFunction<TData>
rootKey?: QueryKey
cancel: (key?: (key: TQueryKey) => QueryKey) => Promise<void>
remove: (key?: (key: TQueryKey) => QueryKey) => Promise<void>
invalidate: (key?: (key: TQueryKey) => QueryKey) => Promise<void>
invalidateRoot: () => void
refetch: () => Promise<TData | undefined>
prefetch: () => Promise<void>
setData: <Data = TData>(
updater: (draft: Draft<Data>) => ValidRecipeReturnType<Draft<Data>>
) => void
setInfiniteData: (
updater: (
draft: Draft<InfiniteData<TData>>
) => ValidRecipeReturnType<Draft<InfiniteData<TData>>>
) => void
getData: () => TData | undefined
optimisticUpdate: <Data = TData>(
updater: (draft: Draft<Data>) => ValidRecipeReturnType<Draft<Data>> | void
) => Promise<{
previousData: Awaited<Data> | undefined
restore: () => void
invalidate: () => void
}>
optimisticInfiniteUpdate: (
updater: (
draft: Draft<InfiniteData<TData>>
) => ValidRecipeReturnType<Draft<InfiniteData<TData>>>
) => Promise<{
previousData: Awaited<InfiniteData<TData>> | undefined
restore: () => void
invalidate: () => void
}>
}>
export type DefinedQueryOptions<TData> = {
// shouldPersist?: boolean;
rootKey?: QueryKey
onCancel?: () => void | Promise<void>
onInvalidate?: () => void | Promise<void>
onInvalidateRoot?: () => void
onRefetch?: (data?: TData) => void
onRefetchRoot?: () => void
onOptimisticUpdate?: () => void
onOptimisticUpdateRestore?: () => void
}
export function defineQuery<
TQueryKey extends QueryKey,
TQueryFn extends QueryFunction<unknown>,
TData = Awaited<ReturnType<TQueryFn>>,
>(
key: TQueryKey,
fn: TQueryFn,
options?: DefinedQueryOptions<TData>
): DefinedQuery<TQueryKey, TData>
export function defineQuery<
TQueryKey extends QueryKey,
TQueryFn extends QueryFunction<any>,
TData = Awaited<ReturnType<TQueryFn>>,
>(key: TQueryKey, fn: TQueryFn, options?: DefinedQueryOptions<TData>) {
const queryDefine: DefinedQuery<TQueryKey, TData> = {
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<TData>(key)
},
setData: (updater) =>
queryClient.setQueryData<TData>(key, (old) => {
if (!old) return
if (typeof updater !== "function") return old
return produce(old, updater)
}),
setInfiniteData: (updater) =>
queryDefine.setData<InfiniteData<TData>>(updater),
getData: () => queryClient.getQueryData<TData>(key),
optimisticUpdate: async <Data = TData>(
updater: (draft: Draft<Data>) => ValidRecipeReturnType<Draft<Data>>,
) => {
await queryClient.cancelQueries({
queryKey: key,
})
const previousData = await queryClient.getQueryData<Data>(key)
await queryClient.setQueryData<Data>(key, (old) => {
if (!old) return
if (typeof updater !== "function") return old
return produce(old, updater)
})
options?.onOptimisticUpdate?.()
return {
previousData,
restore: () => {
queryClient.setQueryData<Data>(key, previousData)
options?.onOptimisticUpdateRestore?.()
},
invalidate: () => {
queryClient.invalidateQueries({ queryKey: key, refetchType: "all" })
options?.onInvalidate?.()
},
}
},
optimisticInfiniteUpdate(updater) {
return queryDefine.optimisticUpdate<InfiniteData<TData>>(updater)
},
}
return Object.freeze(queryDefine)
}

View File

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

View File

@ -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<typeof apiClient.entries.$get>,
{ code: 0 }
>["data"],
undefined
>
>
export type ListResponse<T> = {
code: number

View File

@ -18,7 +18,8 @@ export function Component() {
}, [navigate, preventNavigate])
if (status !== "authenticated") {
return navigate("/login")
navigate("/login")
return null
}
return <Outlet />

View File

@ -22,7 +22,8 @@ export function Component() {
}, [])
if (status === "authenticated") {
return navigate("/redirect?app=follow")
navigate("/redirect?app=follow")
return null
}
return (

View File

@ -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<ListResponse<EntriesResponse>>("/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<ListResponse<EntriesResponse>>("/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<DataResponse<EntriesResponse[number]>>(
"/entries",
{
query: {
id,
},
},
)
return data.data
},
})

View File

@ -0,0 +1,7 @@
import { entries } from "./entries"
import { subscription } from "./subscriptions"
export const Queries = {
subscription,
entries,
}

View File

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

6
src/renderer/src/shim.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module "*?asset" {
const src: string
export default src
}