diff --git a/apps/renderer/src/hooks/biz/useFeedActions.tsx b/apps/renderer/src/hooks/biz/useFeedActions.tsx index eb7740507..2d9ed11e7 100644 --- a/apps/renderer/src/hooks/biz/useFeedActions.tsx +++ b/apps/renderer/src/hooks/biz/useFeedActions.tsx @@ -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 diff --git a/apps/renderer/src/modules/feed-column/category.tsx b/apps/renderer/src/modules/feed-column/category.tsx index 7ab9fe82a..6414f999b 100644 --- a/apps/renderer/src/modules/feed-column/category.tsx +++ b/apps/renderer/src/modules/feed-column/category.tsx @@ -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 ( diff --git a/apps/renderer/src/modules/settings/tabs/lists/index.tsx b/apps/renderer/src/modules/settings/tabs/lists/index.tsx index 289f0cd57..f3bc7f373 100644 --- a/apps/renderer/src/modules/settings/tabs/lists/index.tsx +++ b/apps/renderer/src/modules/settings/tabs/lists/index.tsx @@ -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 = () => {
- - {listList.data?.length ? ( -
- - - - {t.settings("lists.title")} - {t.settings("lists.view")} - {t.settings("lists.fee.label")} - {t.settings("lists.subscriptions")} - {t.settings("lists.earnings")} - - {t.common("words.actions")} - - - - - {listList.data?.map((row) => ( - - - - {row.image && ( - - - - )} - {row.title} - - - - - - - {views[row.view].icon} - - - - {t(views[row.view].name)} - - - - -
- {row.fee} - -
-
- {row.subscriptionCount} - - {BigInt(row.purchaseAmount || 0n)} - - - - - - - - {t.common("words.manage")} - - - - - - - - {t.common("words.edit")} - - - - - - present({ - title: t.settings("lists.delete.confirm"), - content: ({ dismiss }) => ( - { - listList.refetch() - dismiss() - }} - /> - ), - }) - } - > - - - - - {t.common("words.delete")} - - - + {isLoading && ownedLists.length === 0 && ( + + )} + + {isLoading && ownedLists.length > 0 && ( + + )} + {!!ownedLists && ( + + {ownedLists.length > 0 ? ( +
+
+ + + {t.settings("lists.title")} + {t.settings("lists.view")} + {t.settings("lists.fee.label")} + {t.settings("lists.subscriptions")} + {t.settings("lists.earnings")} + + {t.common("words.actions")} + - ))} - -
-
- ) : listList.isLoading ? ( - - ) : ( -
-

{t.settings("lists.noLists")}

-
- )} -
+ + + {ownedLists.map((row) => ( + + + + {row.image && ( + + + + )} + {row.title} + + + + + + + {views[row.view].icon} + + + + {t(views[row.view].name)} + + + + +
+ {row.fee} + +
+
+ + {listDataMap[row.id]?.subscriptionCount} + + + {BigInt(listDataMap[row.id]?.purchaseAmount || 0n)} + + + + + + + + {t.common("words.manage")} + + + + + + + + {t.common("words.edit")} + + + + + + present({ + title: t.settings("lists.delete.confirm"), + content: () => , + }) + } + > + + + + + {t.common("words.delete")} + + + +
+ ))} +
+ +
+ ) : ( +
+

{t.settings("lists.noLists")}

+
+ )} + + )} ) diff --git a/apps/renderer/src/store/list/hooks.ts b/apps/renderer/src/store/list/hooks.ts index 2bd892189..e95c9739c 100644 --- a/apps/renderer/src/store/list/hooks.ts +++ b/apps/renderer/src/store/list/hooks.ts @@ -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), + ) +} diff --git a/apps/renderer/src/store/list/store.ts b/apps/renderer/src/store/list/store.ts index 2cd485dfb..9164d1a46 100644 --- a/apps/renderer/src/store/list/store.ts +++ b/apps/renderer/src/store/list/store.ts @@ -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("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) { diff --git a/apps/renderer/src/store/utils/helper.ts b/apps/renderer/src/store/utils/helper.ts index a3b4b49aa..9e263d4fb 100644 --- a/apps/renderer/src/store/utils/helper.ts +++ b/apps/renderer/src/store/utils/helper.ts @@ -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 export const toRaw = (draft: MayBeDraft): T => { return isDraft(draft) ? original(draft)! : draft } + +const noop = (err: any) => { + console.error(err) +} +export const createTransaction = (snapshot: S) => { + let onRollback: ((snapshot: S) => Promise) | undefined + let executorFn: (snapshot: S) => Promise | undefined + let optimisticExecutor: (snapshot: S) => Promise | undefined + let onPersist: ((snapshot: S) => Promise) | undefined + + const ret = { + rollback: (fn: (snapshot: S) => Promise) => { + onRollback = fn + return ret + }, + execute: (executor: (snapshot: S) => Promise) => { + executorFn = executor + return ret + }, + optimistic: (executor: (snapshot: S) => Promise) => { + 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) => { + onPersist = fn + return ret + }, + } + return ret +}