refactor: setting list data getter

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2024-11-04 17:15:18 +08:00
parent 11ff813c45
commit bf71094110
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
6 changed files with 251 additions and 165 deletions

View File

@ -23,7 +23,7 @@ import {
useRemoveFeedFromFeedList,
} from "~/store/feed"
import { useInboxById } from "~/store/inbox"
import { useListById, useOwnedList } from "~/store/list"
import { useListById, useOwnedListByView } from "~/store/list"
import { subscriptionActions, useSubscriptionByFeedId } from "~/store/subscription"
import { useNavigateEntry } from "./useNavigateEntry"
@ -85,7 +85,7 @@ export const useFeedActions = ({
const { mutateAsync: removeFeedFromListMutation } = useRemoveFeedFromFeedList()
const openBoostModal = useBoostModal()
const listByView = useOwnedList(view!)
const listByView = useOwnedListByView(view!)
const isMultipleSelection = feedIds && feedIds.length > 0

View File

@ -19,7 +19,7 @@ import { ROUTE_FEED_IN_FOLDER } from "~/constants"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { getRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { getPreferredTitle, useAddFeedToFeedList, useFeedStore } from "~/store/feed"
import { useOwnedList } from "~/store/list"
import { useOwnedListByView } from "~/store/list"
import {
subscriptionActions,
subscriptionCategoryExist,
@ -148,7 +148,7 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego
const addMutation = useAddFeedToFeedList()
const listList = useOwnedList(view!)
const listList = useOwnedListByView(view!)
const showContextMenu = useShowContextMenu()
return (

View File

@ -21,43 +21,33 @@ import { views } from "@follow/constants"
import { UrlBuilder } from "@follow/utils/url-builder"
import { cn } from "@follow/utils/utils"
import { useMutation } from "@tanstack/react-query"
import { useMemo } from "react"
import { toast } from "sonner"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useCurrentModal, useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useAuthQuery, useI18n } from "~/hooks/common"
import { apiClient } from "~/lib/api-fetch"
import { Balance } from "~/modules/wallet/balance"
import { Queries } from "~/queries"
import { listActions } from "~/store/list"
import { listActions, useOwnedLists } from "~/store/list"
import { ListCreationModalContent, ListFeedsModalContent } from "./modals"
const ConfirmDestroyModalContent = ({
listId,
onSuccess,
}: {
listId: string
onSuccess: () => void
}) => {
const ConfirmDestroyModalContent = ({ listId }: { listId: string }) => {
const t = useI18n()
const currentModal = useCurrentModal()
const deleteFeedList = useMutation({
mutationFn: async (payload: { listId: string }) => {
listActions.deleteList(payload.listId)
await apiClient.lists.$delete({
json: {
listId: payload.listId,
},
})
},
mutationFn: (payload: { listId: string }) => listActions.deleteList(payload.listId),
onSuccess: () => {
toast.success(t.settings("lists.delete.success"))
Queries.lists.list().invalidate()
onSuccess()
},
async onError() {
onError() {
toast.error(t.settings("lists.delete.error"))
},
onMutate() {
currentModal?.dismiss()
},
})
return (
@ -77,7 +67,26 @@ const ConfirmDestroyModalContent = ({
export const SettingLists = () => {
const t = useI18n()
const listList = useAuthQuery(Queries.lists.list())
const { isLoading, data } = useAuthQuery(Queries.lists.list())
const listDataMap = useMemo(() => {
if (!data) return {}
return data?.reduce(
(acc, curr) => {
acc[curr.id] = {
id: curr.id,
subscriptionCount: curr.subscriptionCount,
purchaseAmount: curr.purchaseAmount,
}
return acc
},
{} as Record<
string,
{ id: string; subscriptionCount: number | undefined; purchaseAmount: number | undefined }
>,
)
}, [data])
const ownedLists = useOwnedLists()
const { present } = useModalStack()
@ -99,140 +108,144 @@ export const SettingLists = () => {
</Button>
<Divider className="mb-6 mt-8" />
<div className="flex flex-1 flex-col">
<ScrollArea.ScrollArea viewportClassName="max-h-[380px]">
{listList.data?.length ? (
<div className="overflow-auto">
<Table className="mt-4">
<TableHeader className="border-b">
<TableRow className="[&_*]:!font-semibold">
<TableHead size="sm">{t.settings("lists.title")}</TableHead>
<TableHead size="sm">{t.settings("lists.view")}</TableHead>
<TableHead size="sm">{t.settings("lists.fee.label")}</TableHead>
<TableHead size="sm">{t.settings("lists.subscriptions")}</TableHead>
<TableHead size="sm">{t.settings("lists.earnings")}</TableHead>
<TableHead size="sm" className="center">
{t.common("words.actions")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody className="border-t-[12px] border-transparent [&_td]:!px-3">
{listList.data?.map((row) => (
<TableRow key={row.title} className="h-8">
<TableCell size="sm">
<a
target="_blank"
href={UrlBuilder.shareList(row.id)}
className="inline-flex items-center gap-2 font-semibold"
>
{row.image && (
<Avatar className="size-6">
<AvatarImage src={row.image} />
</Avatar>
)}
<span className="inline-block max-w-[200px] truncate">{row.title}</span>
</a>
</TableCell>
<TableCell size="sm">
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn("inline-flex items-center", views[row.view].className)}
>
{views[row.view].icon}
</span>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t(views[row.view].name)}</TooltipContent>
</TooltipPortal>
</Tooltip>
</TableCell>
<TableCell size="sm">
<div className="flex items-center gap-1">
{row.fee}
<i className="i-mgc-power shrink-0 text-lg text-accent" />
</div>
</TableCell>
<TableCell size="sm">{row.subscriptionCount}</TableCell>
<TableCell size="sm">
<Balance>{BigInt(row.purchaseAmount || 0n)}</Balance>
</TableCell>
<TableCell size="sm" className="center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
onClick={() => {
present({
title: t.settings("lists.feeds.manage"),
content: () => <ListFeedsModalContent id={row.id} />,
})
}}
>
<i className="i-mgc-inbox-cute-re" />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t.common("words.manage")}</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
onClick={() => {
present({
title: t.settings("lists.edit.label"),
content: () => <ListCreationModalContent id={row.id} />,
})
}}
>
<i className="i-mgc-edit-cute-re" />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t.common("words.edit")}</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ActionButton
size="sm"
onClick={() =>
present({
title: t.settings("lists.delete.confirm"),
content: ({ dismiss }) => (
<ConfirmDestroyModalContent
listId={row.id}
onSuccess={() => {
listList.refetch()
dismiss()
}}
/>
),
})
}
>
<i className="i-mgc-delete-2-cute-re" />
</ActionButton>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t.common("words.delete")}</TooltipContent>
</TooltipPortal>
</Tooltip>
</TableCell>
{isLoading && ownedLists.length === 0 && (
<LoadingCircle size="large" className="center absolute inset-0" />
)}
{isLoading && ownedLists.length > 0 && (
<LoadingCircle size="small" className="center absolute right-0" />
)}
{!!ownedLists && (
<ScrollArea.ScrollArea viewportClassName="max-h-[380px]">
{ownedLists.length > 0 ? (
<div className="overflow-auto">
<Table className="mt-4">
<TableHeader className="border-b">
<TableRow className="[&_*]:!font-semibold">
<TableHead size="sm">{t.settings("lists.title")}</TableHead>
<TableHead size="sm">{t.settings("lists.view")}</TableHead>
<TableHead size="sm">{t.settings("lists.fee.label")}</TableHead>
<TableHead size="sm">{t.settings("lists.subscriptions")}</TableHead>
<TableHead size="sm">{t.settings("lists.earnings")}</TableHead>
<TableHead size="sm" className="center">
{t.common("words.actions")}
</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : listList.isLoading ? (
<LoadingCircle size="large" className="center absolute inset-0" />
) : (
<div className="mt-36 w-full text-center text-sm text-zinc-400">
<p>{t.settings("lists.noLists")}</p>
</div>
)}
</ScrollArea.ScrollArea>
</TableHeader>
<TableBody className="border-t-[12px] border-transparent [&_td]:!px-3">
{ownedLists.map((row) => (
<TableRow key={row.title} className="h-8">
<TableCell size="sm">
<a
target="_blank"
href={UrlBuilder.shareList(row.id)}
className="inline-flex items-center gap-2 font-semibold"
>
{row.image && (
<Avatar className="size-6">
<AvatarImage src={row.image} />
</Avatar>
)}
<span className="inline-block max-w-[200px] truncate">{row.title}</span>
</a>
</TableCell>
<TableCell size="sm">
<Tooltip>
<TooltipTrigger asChild>
<span
className={cn(
"inline-flex items-center",
views[row.view].className,
)}
>
{views[row.view].icon}
</span>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t(views[row.view].name)}</TooltipContent>
</TooltipPortal>
</Tooltip>
</TableCell>
<TableCell size="sm">
<div className="flex items-center gap-1 tabular-nums">
{row.fee}
<i className="i-mgc-power shrink-0 text-lg text-accent" />
</div>
</TableCell>
<TableCell size="sm" className="tabular-nums">
{listDataMap[row.id]?.subscriptionCount}
</TableCell>
<TableCell size="sm" className="tabular-nums">
<Balance>{BigInt(listDataMap[row.id]?.purchaseAmount || 0n)}</Balance>
</TableCell>
<TableCell size="sm" className="center">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
onClick={() => {
present({
title: t.settings("lists.feeds.manage"),
content: () => <ListFeedsModalContent id={row.id} />,
})
}}
>
<i className="i-mgc-inbox-cute-re" />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t.common("words.manage")}</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
onClick={() => {
present({
title: t.settings("lists.edit.label"),
content: () => <ListCreationModalContent id={row.id} />,
})
}}
>
<i className="i-mgc-edit-cute-re" />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t.common("words.edit")}</TooltipContent>
</TooltipPortal>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<ActionButton
size="sm"
onClick={() =>
present({
title: t.settings("lists.delete.confirm"),
content: () => <ConfirmDestroyModalContent listId={row.id} />,
})
}
>
<i className="i-mgc-delete-2-cute-re" />
</ActionButton>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>{t.common("words.delete")}</TooltipContent>
</TooltipPortal>
</Tooltip>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<div className="mt-36 w-full text-center text-sm text-zinc-400">
<p>{t.settings("lists.noLists")}</p>
</div>
)}
</ScrollArea.ScrollArea>
)}
</div>
</section>
)

View File

@ -13,7 +13,7 @@ export const useListByView = (view: FeedViewType) => {
return useListStore((state) => Object.values(state.lists).filter((list) => list.view === view))
}
export const useOwnedList = (view: FeedViewType) => {
export const useOwnedListByView = (view: FeedViewType) => {
const whoami = useWhoami()
const viewLists = useListByView(view)
return useMemo(
@ -21,3 +21,10 @@ export const useOwnedList = (view: FeedViewType) => {
[viewLists, whoami],
)
}
export const useOwnedLists = () => {
const whoami = useWhoami()
return useListStore((state) =>
Object.values(state.lists).filter((list) => list.ownerUserId === whoami?.id),
)
}

View File

@ -1,11 +1,12 @@
import type { FeedModel, ListModel, ListModelPoplutedFeeds } from "@follow/models/types"
import { sleep } from "@follow/utils/utils"
import { runTransactionInScope } from "~/database"
import { apiClient } from "~/lib/api-fetch"
import { ListService } from "~/services/list"
import { feedActions } from "../feed"
import { createImmerSetter, createZustandStore } from "../utils/helper"
import { createImmerSetter, createTransaction, createZustandStore } from "../utils/helper"
import type { ListState } from "./types"
export const useListStore = createZustandStore<ListState>("list")(() => ({
@ -78,13 +79,37 @@ class ListActionStatic {
})
}
deleteList(listId: string) {
immerSet((state) => {
delete state.lists[listId]
return state
async deleteList(listId: string) {
const deletedList = get().lists[listId]
if (!deletedList) return
const tx = createTransaction(deletedList)
tx.optimistic(async () => {
immerSet((state) => {
delete state.lists[listId]
return state
})
})
tx.execute(async () => {
await sleep(1000)
await apiClient.lists.$delete({
json: {
listId,
},
})
})
tx.rollback(async (s) => {
immerSet((state) => {
state.lists[listId] = s
return state
})
})
runTransactionInScope(() => ListService.bulkDelete([listId]))
tx.onPersist(async () => {
ListService.bulkDelete([listId])
})
await tx.run()
}
async fetchListById(id: string) {

View File

@ -1,4 +1,5 @@
/* eslint-disable no-unsafe-finally */
import { isDraft, original, produce } from "immer"
import { unstable_batchedUpdates } from "react-dom"
import type { StateCreator, StoreApi, UseBoundStore } from "zustand"
@ -149,3 +150,43 @@ type MayBeDraft<T> = T
export const toRaw = <T>(draft: MayBeDraft<T>): T => {
return isDraft(draft) ? original(draft)! : draft
}
const noop = (err: any) => {
console.error(err)
}
export const createTransaction = <S>(snapshot: S) => {
let onRollback: ((snapshot: S) => Promise<void>) | undefined
let executorFn: (snapshot: S) => Promise<void> | undefined
let optimisticExecutor: (snapshot: S) => Promise<void> | undefined
let onPersist: ((snapshot: S) => Promise<void>) | undefined
const ret = {
rollback: (fn: (snapshot: S) => Promise<void>) => {
onRollback = fn
return ret
},
execute: (executor: (snapshot: S) => Promise<void>) => {
executorFn = executor
return ret
},
optimistic: (executor: (snapshot: S) => Promise<void>) => {
optimisticExecutor = executor
return ret
},
run: async () => {
await optimisticExecutor?.(snapshot)?.catch(noop)
await executorFn?.(snapshot)?.catch((err) => {
if (onRollback) {
onRollback(snapshot)
}
throw err
})
await runTransactionInScope(() => onPersist?.(snapshot))
},
onPersist: (fn: (snapshot: S) => Promise<void>) => {
onPersist = fn
return ret
},
}
return ret
}