refactor: optimize feed and subscription logic with hooks (#1585)
* perf: memo selector Signed-off-by: Innei <tukon479@gmail.com> * chore: remove hooks Signed-off-by: Innei <tukon479@gmail.com> * feat: rollback data Signed-off-by: Innei <tukon479@gmail.com> --------- Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
2933e5b328
commit
50fd58f824
|
|
@ -4,27 +4,25 @@ import { IN_ELECTRON } from "@follow/shared/constants"
|
|||
import { env } from "@follow/shared/env"
|
||||
import { UrlBuilder } from "@follow/utils/url-builder"
|
||||
import { isBizId } from "@follow/utils/utils"
|
||||
import { useMemo } from "react"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { useMemo, useRef } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
|
||||
import type { FollowMenuItem, MenuItemInput } from "~/atoms/context-menu"
|
||||
import { whoami } from "~/atoms/user"
|
||||
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import { apiClient } from "~/lib/api-fetch"
|
||||
import { useBoostModal } from "~/modules/boost/hooks"
|
||||
import { useFeedClaimModal } from "~/modules/claim"
|
||||
import { FeedForm } from "~/modules/discover/feed-form"
|
||||
import { InboxForm } from "~/modules/discover/inbox-form"
|
||||
import { ListForm } from "~/modules/discover/list-form"
|
||||
import { ListCreationModalContent } from "~/modules/settings/tabs/lists/modals"
|
||||
import {
|
||||
getFeedById,
|
||||
useAddFeedToFeedList,
|
||||
useFeedById,
|
||||
useRemoveFeedFromFeedList,
|
||||
useResetFeed,
|
||||
} from "~/store/feed"
|
||||
import { entries } from "~/queries/entries"
|
||||
import { getFeedById, useFeedById } from "~/store/feed"
|
||||
import { useInboxById } from "~/store/inbox"
|
||||
import { useListById, useOwnedListByView } from "~/store/list"
|
||||
import { listActions, useListById, useOwnedListByView } from "~/store/list"
|
||||
import { subscriptionActions, useSubscriptionByFeedId } from "~/store/subscription"
|
||||
|
||||
import { useNavigateEntry } from "./useNavigateEntry"
|
||||
|
|
@ -469,3 +467,81 @@ export const useInboxActions = ({ inboxId }: { inboxId: string }) => {
|
|||
|
||||
return { items }
|
||||
}
|
||||
|
||||
export const useAddFeedToFeedList = (options?: {
|
||||
onSuccess?: () => void
|
||||
onError?: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation("settings")
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
payload: { feedId: string; listId: string } | { feedIds: string[]; listId: string },
|
||||
) => {
|
||||
const feeds = await apiClient.lists.feeds.$post({
|
||||
json: payload,
|
||||
})
|
||||
|
||||
feeds.data.forEach((feed) => listActions.addFeedToFeedList(payload.listId, feed))
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("lists.feeds.add.success"))
|
||||
|
||||
options?.onSuccess?.()
|
||||
},
|
||||
async onError() {
|
||||
toast.error(t("lists.feeds.add.error"))
|
||||
options?.onError?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useRemoveFeedFromFeedList = (options?: {
|
||||
onSuccess: () => void
|
||||
onError: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation("settings")
|
||||
return useMutation({
|
||||
mutationFn: async (payload: { feedId: string; listId: string }) => {
|
||||
listActions.removeFeedFromFeedList(payload.listId, payload.feedId)
|
||||
await apiClient.lists.feeds.$delete({
|
||||
json: {
|
||||
listId: payload.listId,
|
||||
feedId: payload.feedId,
|
||||
},
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("lists.feeds.delete.success"))
|
||||
options?.onSuccess?.()
|
||||
},
|
||||
async onError() {
|
||||
toast.error(t("lists.feeds.delete.error"))
|
||||
options?.onError?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useResetFeed = () => {
|
||||
const { t } = useTranslation()
|
||||
const toastIDRef = useRef<string | number | null>(null)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (feedId: string) => {
|
||||
toastIDRef.current = toast.loading(t("sidebar.feed_actions.resetting_feed"))
|
||||
await apiClient.feeds.reset.$get({ query: { id: feedId } })
|
||||
},
|
||||
onSuccess: (_, feedId) => {
|
||||
entries.entries({ feedId }).invalidateRoot()
|
||||
toast.success(
|
||||
t("sidebar.feed_actions.reset_feed_success"),
|
||||
toastIDRef.current ? { id: toastIDRef.current } : undefined,
|
||||
)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
t("sidebar.feed_actions.reset_feed_error"),
|
||||
toastIDRef.current ? { id: toastIDRef.current } : undefined,
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,18 +11,19 @@ import {
|
|||
import { FeedViewType, UserRole } from "@follow/constants"
|
||||
import { env } from "@follow/shared/env"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { memo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
import { useEventCallback } from "usehooks-ts"
|
||||
|
||||
import { useUserRole } from "~/atoms/user"
|
||||
import { CopyButton } from "~/components/ui/code-highlighter"
|
||||
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import { useCurrentModal, useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import { MAX_TRIAL_USER_INBOX_SUBSCRIPTION } from "~/constants/limit"
|
||||
import { CustomSafeError } from "~/errors/CustomSafeError"
|
||||
import { createErrorToaster } from "~/lib/error-parser"
|
||||
import { useInboxList } from "~/queries/inboxes"
|
||||
import { inboxActions } from "~/store/inbox"
|
||||
import { inboxActions, useInboxById } from "~/store/inbox"
|
||||
import { subscriptionActions, useInboxSubscriptionCount } from "~/store/subscription"
|
||||
|
||||
import { useActivationModal } from "../activation"
|
||||
|
|
@ -97,68 +98,7 @@ export function DiscoverInboxList() {
|
|||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
inboxes.data?.map((inbox) => (
|
||||
<TableRow key={inbox.id}>
|
||||
<TableCell size="sm">{inbox.id}</TableCell>
|
||||
<TableCell size="sm">
|
||||
<div className="group relative flex w-fit items-center gap-2">
|
||||
<span className="shrink-0">
|
||||
{inbox.id}
|
||||
{env.VITE_INBOXES_EMAIL}
|
||||
</span>
|
||||
<CopyButton
|
||||
value={`${inbox.id}${env.VITE_INBOXES_EMAIL}`}
|
||||
className="absolute -right-6 p-1 opacity-0 group-hover:opacity-100 [&_i]:size-3"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell size="sm">{inbox.title}</TableCell>
|
||||
<TableCell size="sm">
|
||||
<div className="group relative flex w-fit items-center gap-2 font-mono">
|
||||
<span className="shrink-0">****</span>
|
||||
<CopyButton
|
||||
value={inbox.secret}
|
||||
className="absolute -right-6 p-1 opacity-0 group-hover:opacity-100 [&_i]:size-3"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell size="sm" className="center">
|
||||
<ActionButton
|
||||
size="sm"
|
||||
tooltip={t("discover.inbox_destroy")}
|
||||
onClick={() =>
|
||||
present({
|
||||
title: t("discover.inbox_destroy_confirm"),
|
||||
content: ({ dismiss }) => (
|
||||
<ConfirmDestroyModalContent
|
||||
id={inbox.id}
|
||||
onSuccess={() => {
|
||||
inboxes.refetch()
|
||||
dismiss()
|
||||
}}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
>
|
||||
<i className="i-mgc-delete-2-cute-re" />
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
present({
|
||||
title: t("sidebar.feed_actions.edit_inbox"),
|
||||
content: ({ dismiss }) => (
|
||||
<InboxForm asWidget id={inbox.id} onSuccess={dismiss} />
|
||||
),
|
||||
})
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-edit-cute-re" />
|
||||
</ActionButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
inboxes.data?.map((inbox) => <Row id={inbox.id} key={inbox.id} />)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
|
@ -190,8 +130,9 @@ export function DiscoverInboxList() {
|
|||
)
|
||||
}
|
||||
|
||||
const ConfirmDestroyModalContent = ({ id, onSuccess }: { id: string; onSuccess: () => void }) => {
|
||||
const ConfirmDestroyModalContent = ({ id }: { id: string }) => {
|
||||
const { t } = useTranslation()
|
||||
const { dismiss } = useCurrentModal()
|
||||
|
||||
const mutationDestroy = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
|
|
@ -200,7 +141,9 @@ const ConfirmDestroyModalContent = ({ id, onSuccess }: { id: string; onSuccess:
|
|||
onSuccess: () => {
|
||||
subscriptionActions.fetchByView(FeedViewType.Articles)
|
||||
toast.success(t("discover.inbox_destroy_success"))
|
||||
onSuccess()
|
||||
},
|
||||
onMutate: () => {
|
||||
dismiss()
|
||||
},
|
||||
onError: createErrorToaster(t("discover.inbox_destroy_error")),
|
||||
})
|
||||
|
|
@ -219,3 +162,62 @@ const ConfirmDestroyModalContent = ({ id, onSuccess }: { id: string; onSuccess:
|
|||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Row = memo(({ id }: { id: string }) => {
|
||||
const { t } = useTranslation()
|
||||
const { present } = useModalStack()
|
||||
const inbox = useInboxById(id)
|
||||
if (!inbox) return null
|
||||
return (
|
||||
<TableRow key={inbox.id}>
|
||||
<TableCell size="sm">{inbox.id}</TableCell>
|
||||
<TableCell size="sm">
|
||||
<div className="group relative flex w-fit items-center gap-2">
|
||||
<span className="shrink-0">
|
||||
{inbox.id}
|
||||
{env.VITE_INBOXES_EMAIL}
|
||||
</span>
|
||||
<CopyButton
|
||||
value={`${inbox.id}${env.VITE_INBOXES_EMAIL}`}
|
||||
className="absolute -right-6 p-1 opacity-0 group-hover:opacity-100 [&_i]:size-3"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell size="sm">{inbox.title}</TableCell>
|
||||
<TableCell size="sm">
|
||||
<div className="group relative flex w-fit items-center gap-2 font-mono">
|
||||
<span className="shrink-0">****</span>
|
||||
<CopyButton
|
||||
value={inbox.secret}
|
||||
className="absolute -right-6 p-1 opacity-0 group-hover:opacity-100 [&_i]:size-3"
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell size="sm" className="center">
|
||||
<ActionButton
|
||||
size="sm"
|
||||
tooltip={t("discover.inbox_destroy")}
|
||||
onClick={() =>
|
||||
present({
|
||||
title: t("discover.inbox_destroy_confirm"),
|
||||
content: () => <ConfirmDestroyModalContent id={inbox.id} />,
|
||||
})
|
||||
}
|
||||
>
|
||||
<i className="i-mgc-delete-2-cute-re" />
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
present({
|
||||
title: t("sidebar.feed_actions.edit_inbox"),
|
||||
content: ({ dismiss }) => <InboxForm asWidget id={inbox.id} onSuccess={dismiss} />,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-edit-cute-re" />
|
||||
</ActionButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Tabs, TabsList, TabsTrigger } from "@follow/components/ui/tabs/index.jsx"
|
||||
import { useCallback } from "react"
|
||||
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { useRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
|
|
@ -9,11 +10,17 @@ export const TimelineTabs = () => {
|
|||
const routerParams = useRouteParams()
|
||||
const { view, listId, inboxId } = routerParams
|
||||
|
||||
const listsData = useSubscriptionStore((state) =>
|
||||
state.feedIdByView[view].map((id) => state.data[id]).filter((s) => "listId" in s),
|
||||
const listsData = useSubscriptionStore(
|
||||
useCallback(
|
||||
(state) => state.feedIdByView[view].map((id) => state.data[id]).filter((s) => "listId" in s),
|
||||
[view],
|
||||
),
|
||||
)
|
||||
const inboxData = useSubscriptionStore((state) =>
|
||||
state.feedIdByView[view].map((id) => state.data[id]).filter((s) => "inboxId" in s),
|
||||
const inboxData = useSubscriptionStore(
|
||||
useCallback(
|
||||
(state) => state.feedIdByView[view].map((id) => state.data[id]).filter((s) => "inboxId" in s),
|
||||
[view],
|
||||
),
|
||||
)
|
||||
const hasData = listsData.length > 0 || inboxData.length > 0
|
||||
|
||||
|
|
|
|||
|
|
@ -18,10 +18,11 @@ import { useOnClickOutside } from "usehooks-ts"
|
|||
import type { MenuItemInput } from "~/atoms/context-menu"
|
||||
import { useShowContextMenu } from "~/atoms/context-menu"
|
||||
import { ROUTE_FEED_IN_FOLDER } from "~/constants"
|
||||
import { useAddFeedToFeedList } from "~/hooks/biz/useFeedActions"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { getRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { createErrorToaster } from "~/lib/error-parser"
|
||||
import { getPreferredTitle, useAddFeedToFeedList, useFeedStore } from "~/store/feed"
|
||||
import { getPreferredTitle, useFeedStore } from "~/store/feed"
|
||||
import { useOwnedListByView } from "~/store/list"
|
||||
import {
|
||||
subscriptionActions,
|
||||
|
|
@ -48,8 +49,8 @@ interface FeedCategoryProps {
|
|||
function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCategoryProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const sortByUnreadFeedList = useFeedUnreadStore((state) =>
|
||||
ids.sort((a, b) => (state.data[b] || 0) - (state.data[a] || 0)),
|
||||
const sortByUnreadFeedList = useFeedUnreadStore(
|
||||
useCallback((state) => ids.sort((a, b) => (state.data[b] || 0) - (state.data[a] || 0)), [ids]),
|
||||
)
|
||||
|
||||
const navigate = useNavigateEntry()
|
||||
|
|
@ -126,8 +127,8 @@ function FeedCategoryImpl({ data: ids, view, categoryOpenStateData }: FeedCatego
|
|||
}
|
||||
}
|
||||
|
||||
const unread = useFeedUnreadStore((state) =>
|
||||
ids.reduce((acc, feedId) => (state.data[feedId] || 0) + acc, 0),
|
||||
const unread = useFeedUnreadStore(
|
||||
useCallback((state) => ids.reduce((acc, feedId) => (state.data[feedId] || 0) + acc, 0), [ids]),
|
||||
)
|
||||
|
||||
const isActive = useRouteParamsSelector(
|
||||
|
|
@ -452,18 +453,23 @@ const SortedFeedItems = (props: SortListProps) => {
|
|||
const SortByAlphabeticalList = (props: SortListProps) => {
|
||||
const { ids, showCollapse, view } = props
|
||||
const isDesc = useFeedListSortSelector((s) => s.order === "desc")
|
||||
const sortedFeedList = useFeedStore((state) => {
|
||||
const res = ids.sort((a, b) => {
|
||||
const feedTitleA = getPreferredTitle(state.feeds[a]) || ""
|
||||
const feedTitleB = getPreferredTitle(state.feeds[b]) || ""
|
||||
return sortByAlphabet(feedTitleA, feedTitleB)
|
||||
})
|
||||
const sortedFeedList = useFeedStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const res = ids.sort((a, b) => {
|
||||
const feedTitleA = getPreferredTitle(state.feeds[a]) || ""
|
||||
const feedTitleB = getPreferredTitle(state.feeds[b]) || ""
|
||||
return sortByAlphabet(feedTitleA, feedTitleB)
|
||||
})
|
||||
|
||||
if (isDesc) {
|
||||
return res
|
||||
}
|
||||
return res.reverse()
|
||||
})
|
||||
if (isDesc) {
|
||||
return res
|
||||
}
|
||||
return res.reverse()
|
||||
},
|
||||
[ids, isDesc],
|
||||
),
|
||||
)
|
||||
return (
|
||||
<Fragment>
|
||||
{sortedFeedList.map((feedId) => (
|
||||
|
|
@ -479,10 +485,15 @@ const SortByAlphabeticalList = (props: SortListProps) => {
|
|||
}
|
||||
const SortByUnreadList = ({ ids, showCollapse, view }: SortListProps) => {
|
||||
const isDesc = useFeedListSortSelector((s) => s.order === "desc")
|
||||
const sortByUnreadFeedList = useFeedUnreadStore((state) => {
|
||||
const res = ids.sort((a, b) => (state.data[b] || 0) - (state.data[a] || 0))
|
||||
return isDesc ? res : res.reverse()
|
||||
})
|
||||
const sortByUnreadFeedList = useFeedUnreadStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const res = ids.sort((a, b) => (state.data[b] || 0) - (state.data[a] || 0))
|
||||
return isDesc ? res : res.reverse()
|
||||
},
|
||||
[ids, isDesc],
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
|
|
|||
|
|
@ -51,17 +51,22 @@ const useBackHome = (active: number) => {
|
|||
const useUnreadByView = () => {
|
||||
useAuthQuery(Queries.subscription.byView())
|
||||
const idByView = useSubscriptionStore((state) => state.feedIdByView)
|
||||
const totalUnread = useFeedUnreadStore((state) => {
|
||||
const unread = {} as Record<number, number>
|
||||
const totalUnread = useFeedUnreadStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const unread = {} as Record<number, number>
|
||||
|
||||
for (const view in idByView) {
|
||||
unread[view] = idByView[view].reduce(
|
||||
(acc: number, feedId: string) => acc + (state.data[feedId] || 0),
|
||||
0,
|
||||
)
|
||||
}
|
||||
return unread
|
||||
})
|
||||
for (const view in idByView) {
|
||||
unread[view] = idByView[view].reduce(
|
||||
(acc: number, feedId: string) => acc + (state.data[feedId] || 0),
|
||||
0,
|
||||
)
|
||||
}
|
||||
return unread
|
||||
},
|
||||
[idByView],
|
||||
),
|
||||
)
|
||||
|
||||
return totalUnread
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { stopPropagation } from "@follow/utils/dom"
|
|||
import { cn } from "@follow/utils/utils"
|
||||
import * as HoverCard from "@radix-ui/react-hover-card"
|
||||
import { AnimatePresence, m } from "framer-motion"
|
||||
import { memo, useMemo, useRef, useState } from "react"
|
||||
import { memo, useCallback, useMemo, useRef, useState } from "react"
|
||||
import { isHotkeyPressed } from "react-hotkeys-hook"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link } from "react-router-dom"
|
||||
|
|
@ -315,16 +315,21 @@ const ListHeader = ({ view }: { view: number }) => {
|
|||
const expansion = Object.values(categoryOpenStateData).every((value) => value === true)
|
||||
useUpdateUnreadCount()
|
||||
|
||||
const totalUnread = useFeedUnreadStore((state) => {
|
||||
let unread = 0
|
||||
const totalUnread = useFeedUnreadStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
let unread = 0
|
||||
|
||||
for (const category in feedsData) {
|
||||
for (const feedId of feedsData[category]) {
|
||||
unread += state.data[feedId] || 0
|
||||
}
|
||||
}
|
||||
return unread
|
||||
})
|
||||
for (const category in feedsData) {
|
||||
for (const feedId of feedsData[category]) {
|
||||
unread += state.data[feedId] || 0
|
||||
}
|
||||
}
|
||||
return unread
|
||||
},
|
||||
[feedsData],
|
||||
),
|
||||
)
|
||||
|
||||
const navigateEntry = useNavigateEntry()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { sortByAlphabet } from "@follow/utils/utils"
|
||||
import { Fragment } from "react"
|
||||
import { Fragment, useCallback } from "react"
|
||||
|
||||
import { INBOX_PREFIX_ID } from "~/constants"
|
||||
import { getPreferredTitle, useFeedStore } from "~/store/feed"
|
||||
|
|
@ -15,45 +15,55 @@ export const SortByAlphabeticalFeedList = ({
|
|||
data,
|
||||
categoryOpenStateData,
|
||||
}: FeedListProps) => {
|
||||
const feedId2CategoryMap = useSubscriptionStore((state) => {
|
||||
const map = {} as Record<string, string>
|
||||
for (const categoryName in data) {
|
||||
const feedId = data[categoryName][0]
|
||||
if (!feedId) {
|
||||
continue
|
||||
}
|
||||
const subscription = state.data[feedId]
|
||||
if (!subscription) {
|
||||
continue
|
||||
}
|
||||
if (subscription.category) {
|
||||
map[feedId] = subscription.category
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
const categoryName2RealDisplayNameMap = useFeedStore((state) => {
|
||||
const map = {} as Record<string, string>
|
||||
for (const categoryName in data) {
|
||||
const feedId = data[categoryName][0]
|
||||
const feedId2CategoryMap = useSubscriptionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const map = {} as Record<string, string>
|
||||
for (const categoryName in data) {
|
||||
const feedId = data[categoryName][0]
|
||||
if (!feedId) {
|
||||
continue
|
||||
}
|
||||
const subscription = state.data[feedId]
|
||||
if (!subscription) {
|
||||
continue
|
||||
}
|
||||
if (subscription.category) {
|
||||
map[feedId] = subscription.category
|
||||
}
|
||||
}
|
||||
return map
|
||||
},
|
||||
[data],
|
||||
),
|
||||
)
|
||||
const categoryName2RealDisplayNameMap = useFeedStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const map = {} as Record<string, string>
|
||||
for (const categoryName in data) {
|
||||
const feedId = data[categoryName][0]
|
||||
|
||||
if (!feedId) {
|
||||
continue
|
||||
}
|
||||
const feed = state.feeds[feedId]
|
||||
if (!feed) {
|
||||
continue
|
||||
}
|
||||
const hascategoryNameNotDefault = !!feedId2CategoryMap[feedId]
|
||||
const isSingle = data[categoryName].length === 1
|
||||
if (!isSingle || hascategoryNameNotDefault) {
|
||||
map[categoryName] = categoryName
|
||||
} else {
|
||||
map[categoryName] = getPreferredTitle(feed)!
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
if (!feedId) {
|
||||
continue
|
||||
}
|
||||
const feed = state.feeds[feedId]
|
||||
if (!feed) {
|
||||
continue
|
||||
}
|
||||
const hascategoryNameNotDefault = !!feedId2CategoryMap[feedId]
|
||||
const isSingle = data[categoryName].length === 1
|
||||
if (!isSingle || hascategoryNameNotDefault) {
|
||||
map[categoryName] = categoryName
|
||||
} else {
|
||||
map[categoryName] = getPreferredTitle(feed)!
|
||||
}
|
||||
}
|
||||
return map
|
||||
},
|
||||
[data, feedId2CategoryMap],
|
||||
),
|
||||
)
|
||||
|
||||
const isDesc = useFeedListSortSelector((s) => s.order === "desc")
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Fragment } from "react"
|
||||
import { Fragment, useCallback } from "react"
|
||||
|
||||
import { useFeedUnreadStore } from "~/store/unread"
|
||||
|
||||
|
|
@ -9,26 +9,34 @@ import type { FeedListProps } from "./types"
|
|||
export const SortByUnreadFeedList = ({ view, data, categoryOpenStateData }: FeedListProps) => {
|
||||
const isDesc = useFeedListSortSelector((s) => s.order === "desc")
|
||||
|
||||
const sortedByUnread = useFeedUnreadStore((state) => {
|
||||
const sortedList = [] as [string, string[]][]
|
||||
const folderUnread = {} as Record<string, number>
|
||||
// Calc total unread count for each folder
|
||||
for (const category in data) {
|
||||
folderUnread[category] = data[category].reduce((acc, cur) => (state.data[cur] || 0) + acc, 0)
|
||||
}
|
||||
const sortedByUnread = useFeedUnreadStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const sortedList = [] as [string, string[]][]
|
||||
const folderUnread = {} as Record<string, number>
|
||||
// Calc total unread count for each folder
|
||||
for (const category in data) {
|
||||
folderUnread[category] = data[category].reduce(
|
||||
(acc, cur) => (state.data[cur] || 0) + acc,
|
||||
0,
|
||||
)
|
||||
}
|
||||
|
||||
// Sort by unread count
|
||||
Object.keys(folderUnread)
|
||||
.sort((a, b) => folderUnread[b] - folderUnread[a])
|
||||
.forEach((key) => {
|
||||
sortedList.push([key, data[key]])
|
||||
})
|
||||
// Sort by unread count
|
||||
Object.keys(folderUnread)
|
||||
.sort((a, b) => folderUnread[b] - folderUnread[a])
|
||||
.forEach((key) => {
|
||||
sortedList.push([key, data[key]])
|
||||
})
|
||||
|
||||
if (!isDesc) {
|
||||
sortedList.reverse()
|
||||
}
|
||||
return sortedList
|
||||
})
|
||||
if (!isDesc) {
|
||||
sortedList.reverse()
|
||||
}
|
||||
return sortedList
|
||||
},
|
||||
[data, isDesc],
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
|
|
|||
|
|
@ -35,20 +35,16 @@ import { z } from "zod"
|
|||
import type { Suggestion } from "~/components/ui/auto-completion"
|
||||
import { Autocomplete } from "~/components/ui/auto-completion"
|
||||
import { useCurrentModal } from "~/components/ui/modal/stacked/hooks"
|
||||
import { useAddFeedToFeedList, useRemoveFeedFromFeedList } from "~/hooks/biz/useFeedActions"
|
||||
import { apiClient } from "~/lib/api-fetch"
|
||||
import { createErrorToaster } from "~/lib/error-parser"
|
||||
import { FeedCertification } from "~/modules/feed/feed-certification"
|
||||
import { FeedIcon } from "~/modules/feed/feed-icon"
|
||||
import { ViewSelectorRadioGroup } from "~/modules/shared/ViewSelectorRadioGroup"
|
||||
import { Queries } from "~/queries"
|
||||
import {
|
||||
getFeedById,
|
||||
useAddFeedToFeedList,
|
||||
useFeedById,
|
||||
useRemoveFeedFromFeedList,
|
||||
} from "~/store/feed"
|
||||
import { useFeedById } from "~/store/feed"
|
||||
import { useListById } from "~/store/list"
|
||||
import { subscriptionActions, useSubscriptionStore } from "~/store/subscription"
|
||||
import { subscriptionActions, useAllFeeds } from "~/store/subscription"
|
||||
|
||||
const formSchema = z.object({
|
||||
view: z.string(),
|
||||
|
|
@ -224,21 +220,7 @@ export const ListFeedsModalContent = ({ id }: { id: string }) => {
|
|||
},
|
||||
})
|
||||
|
||||
const allFeeds = useSubscriptionStore((store) => {
|
||||
const feedInfo = [] as { title: string; id: string }[]
|
||||
|
||||
const allSubscriptions = Object.values(store.feedIdByView).flat()
|
||||
|
||||
for (const feedId of allSubscriptions) {
|
||||
const subscription = store.data[feedId]
|
||||
const feed = getFeedById(feedId)
|
||||
if (feed && feed.type === "feed") {
|
||||
feedInfo.push({ title: subscription.title || feed.title || "", id: feed.id })
|
||||
}
|
||||
}
|
||||
return feedInfo
|
||||
})
|
||||
|
||||
const allFeeds = useAllFeeds()
|
||||
const autocompleteSuggestions: Suggestion[] = useMemo(() => {
|
||||
return allFeeds
|
||||
.filter((feed) => !list?.feedIds?.includes(feed.id))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { FeedViewType } from "@follow/constants"
|
||||
import type { EntryReadHistoriesModel } from "@follow/shared/hono"
|
||||
import { useShallow } from "zustand/react/shallow"
|
||||
import { useCallback } from "react"
|
||||
|
||||
import { FEED_COLLECTION_LIST, ROUTE_FEED_IN_FOLDER } from "~/constants"
|
||||
|
||||
|
|
@ -14,79 +14,85 @@ export const useEntry = <T = FlatEntryModel>(
|
|||
selector?: (state: FlatEntryModel) => T,
|
||||
): T | null =>
|
||||
useEntryStore(
|
||||
useShallow((state) => {
|
||||
if (!entryId) return null
|
||||
const data = state.flatMapEntries[entryId]
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!entryId) return null
|
||||
const data = state.flatMapEntries[entryId]
|
||||
|
||||
if (!data) return null
|
||||
if (!data) return null
|
||||
|
||||
return selector ? selector(data) : (data as T)
|
||||
}),
|
||||
return selector ? selector(data) : (data as T)
|
||||
},
|
||||
[entryId, selector],
|
||||
),
|
||||
)
|
||||
|
||||
// feedId: single feedId, multiple feedId joint by `,`, and `collections`
|
||||
export const useEntryIdsByFeedId = (feedId: string, filter?: EntryFilter) =>
|
||||
useEntryStore(
|
||||
useShallow((state) => {
|
||||
if (typeof feedId !== "string") return []
|
||||
const isMultiple = feedId.includes(",")
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (typeof feedId !== "string") return []
|
||||
const isMultiple = feedId.includes(",")
|
||||
|
||||
const isInFolder = feedId.startsWith(ROUTE_FEED_IN_FOLDER)
|
||||
const isInFolder = feedId.startsWith(ROUTE_FEED_IN_FOLDER)
|
||||
|
||||
if (isMultiple) {
|
||||
const feedIds = feedId.split(",")
|
||||
const result = [] as string[]
|
||||
for (const id of feedIds) {
|
||||
result.push(...getSingle(id))
|
||||
}
|
||||
return result
|
||||
} else if (feedId === FEED_COLLECTION_LIST) {
|
||||
const result = [] as string[]
|
||||
state.starIds.forEach((entryId) => {
|
||||
if (getEntryIsInView(entryId)?.toString() === filter?.view?.toString()) {
|
||||
result.push(entryId)
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
} else if (isInFolder) {
|
||||
// please use `useEntryIdsByFolderName` instead
|
||||
return []
|
||||
} else {
|
||||
return getSingle(feedId)
|
||||
}
|
||||
|
||||
function getSingle(feedId: string) {
|
||||
const data = state.entries[feedId] || []
|
||||
if (filter?.unread) {
|
||||
if (isMultiple) {
|
||||
const feedIds = feedId.split(",")
|
||||
const result = [] as string[]
|
||||
for (const entryId of data) {
|
||||
const entry = state.flatMapEntries[entryId]
|
||||
if (!entry?.read) {
|
||||
result.push(entryId)
|
||||
}
|
||||
for (const id of feedIds) {
|
||||
result.push(...getSingle(id))
|
||||
}
|
||||
return result
|
||||
} else if (feedId === FEED_COLLECTION_LIST) {
|
||||
const result = [] as string[]
|
||||
state.starIds.forEach((entryId) => {
|
||||
if (getEntryIsInView(entryId)?.toString() === filter?.view?.toString()) {
|
||||
result.push(entryId)
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
} else if (isInFolder) {
|
||||
// please use `useEntryIdsByFolderName` instead
|
||||
return []
|
||||
} else {
|
||||
return getSingle(feedId)
|
||||
}
|
||||
return data
|
||||
}
|
||||
}),
|
||||
|
||||
function getSingle(feedId: string) {
|
||||
const data = state.entries[feedId] || []
|
||||
if (filter?.unread) {
|
||||
const result = [] as string[]
|
||||
for (const entryId of data) {
|
||||
const entry = state.flatMapEntries[entryId]
|
||||
if (!entry?.read) {
|
||||
result.push(entryId)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
return data
|
||||
}
|
||||
},
|
||||
[feedId, filter?.unread, filter?.view],
|
||||
),
|
||||
)
|
||||
|
||||
export const useEntryIdsByView = (view: FeedViewType, filter?: EntryFilter) => {
|
||||
const feedIds = useFeedIdByView(view)
|
||||
|
||||
return useEntryStore(useShallow(() => getFilteredFeedIds(feedIds, filter)))
|
||||
return useEntryStore(useCallback(() => getFilteredFeedIds(feedIds, filter), [feedIds, filter]))
|
||||
}
|
||||
|
||||
export const useEntryIdsByFeedIds = (feedIds: string[], filter: EntryFilter = {}) =>
|
||||
useEntryStore(
|
||||
useShallow(() => {
|
||||
useCallback(() => {
|
||||
if (!feedIds) return null
|
||||
if (!Array.isArray(feedIds)) return null
|
||||
|
||||
return getFilteredFeedIds(feedIds, filter)
|
||||
}),
|
||||
}, [feedIds, filter]),
|
||||
)
|
||||
export const useEntryIdsByFeedIdOrView = (
|
||||
feedIdOrView: string | string[] | FeedViewType,
|
||||
|
|
@ -107,4 +113,4 @@ export const useEntryIdsByFeedIdOrView = (
|
|||
export const useEntryReadHistory = (
|
||||
entryId: string,
|
||||
): Omit<EntryReadHistoriesModel, "entryId"> | null =>
|
||||
useEntryStore(useShallow((state) => state.readHistory[entryId]))
|
||||
useEntryStore((state) => state.readHistory[entryId])
|
||||
|
|
|
|||
|
|
@ -1,47 +1,50 @@
|
|||
import { views } from "@follow/constants"
|
||||
import type { FeedModel, FeedOrListRespModel, InboxModel, ListModel } from "@follow/models/types"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { useRef } from "react"
|
||||
import { useCallback } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { toast } from "sonner"
|
||||
import { useShallow } from "zustand/react/shallow"
|
||||
|
||||
import { FEED_COLLECTION_LIST, ROUTE_FEED_IN_FOLDER, ROUTE_FEED_PENDING } from "~/constants"
|
||||
import { useRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { apiClient } from "~/lib/api-fetch"
|
||||
import { entries } from "~/queries/entries"
|
||||
|
||||
import { useInboxStore } from "../inbox"
|
||||
import { listActions, useListStore } from "../list"
|
||||
import { useListStore } from "../list"
|
||||
import {
|
||||
feedByIdOrUrlSelector,
|
||||
feedByIdSelector,
|
||||
feedByIdSelectorWithTransform,
|
||||
feedByIdWithTransformSelector,
|
||||
inboxByIdSelectorWithTransform,
|
||||
listByIdSelectorWithTransform,
|
||||
} from "./selector"
|
||||
import { getPreferredTitle, useFeedStore } from "./store"
|
||||
import type { FeedQueryParams } from "./types"
|
||||
|
||||
export function useFeedById(feedId: Nullable<string>): FeedModel | null
|
||||
export function useFeedById<T>(feedId: Nullable<string>, selector: (feed: FeedModel) => T): T | null
|
||||
export function useFeedById<T>(feedId: Nullable<string>, selector?: (feed: FeedModel) => T) {
|
||||
return useFeedStore((state) => {
|
||||
const feed = feedId ? state.feeds[feedId] : null
|
||||
return selector ? feed && selector(feed) : feed
|
||||
})
|
||||
export function useFeedById<T>(feedId: Nullable<string>, transform?: (feed: FeedModel) => T) {
|
||||
return useFeedStore(
|
||||
useCallback(
|
||||
(state) =>
|
||||
transform
|
||||
? feedByIdWithTransformSelector(feedId, transform)(state)
|
||||
: feedByIdSelector(feedId)(state),
|
||||
[feedId, transform],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const useFeedByIdOrUrl = (feed: FeedQueryParams) =>
|
||||
useFeedStore((state) => {
|
||||
if (feed.id) {
|
||||
return state.feeds[feed.id]
|
||||
}
|
||||
if (feed.url) {
|
||||
return Object.values(state.feeds).find((f) => f.type === "feed" && f.url === feed.url) || null
|
||||
}
|
||||
return null
|
||||
})
|
||||
useFeedStore(useCallback((state) => feedByIdOrUrlSelector(feed)(state), [feed]))
|
||||
|
||||
export const useFeedByIdSelector = <T>(
|
||||
feedId: Nullable<string>,
|
||||
selector: (feed: FeedOrListRespModel) => T,
|
||||
) =>
|
||||
useFeedStore(
|
||||
useShallow((state) => (feedId && state.feeds[feedId] ? selector(state.feeds[feedId]) : null)),
|
||||
useCallback(
|
||||
(state) => feedByIdSelectorWithTransform(feedId, selector)(state),
|
||||
[feedId, selector],
|
||||
),
|
||||
)
|
||||
|
||||
export const useListByIdSelector = <T>(
|
||||
|
|
@ -49,7 +52,10 @@ export const useListByIdSelector = <T>(
|
|||
selector: (list: ListModel) => T,
|
||||
) =>
|
||||
useListStore(
|
||||
useShallow((state) => (listId && state.lists[listId] ? selector(state.lists[listId]) : null)),
|
||||
useCallback(
|
||||
(state) => listByIdSelectorWithTransform(listId, selector)(state),
|
||||
[listId, selector],
|
||||
),
|
||||
)
|
||||
|
||||
export const useInboxByIdSelector = <T>(
|
||||
|
|
@ -57,8 +63,9 @@ export const useInboxByIdSelector = <T>(
|
|||
selector: (inbox: InboxModel) => T,
|
||||
) =>
|
||||
useInboxStore(
|
||||
useShallow((state) =>
|
||||
inboxId && state.inboxes[inboxId] ? selector(state.inboxes[inboxId]) : null,
|
||||
useCallback(
|
||||
(state) => inboxByIdSelectorWithTransform(inboxId, selector)(state),
|
||||
[inboxId, selector],
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -67,9 +74,9 @@ export const useFeedHeaderTitle = () => {
|
|||
|
||||
const { feedId: currentFeedId, view, listId, inboxId } = useRouteParams()
|
||||
|
||||
const listTitle = useListByIdSelector(listId, (list) => getPreferredTitle(list))
|
||||
const inboxTitle = useInboxByIdSelector(inboxId, (inbox) => getPreferredTitle(inbox))
|
||||
const feedTitle = useFeedByIdSelector(currentFeedId, (feed) => getPreferredTitle(feed))
|
||||
const listTitle = useListByIdSelector(listId, getPreferredTitle)
|
||||
const inboxTitle = useInboxByIdSelector(inboxId, getPreferredTitle)
|
||||
const feedTitle = useFeedByIdSelector(currentFeedId, getPreferredTitle)
|
||||
|
||||
switch (currentFeedId) {
|
||||
case ROUTE_FEED_PENDING: {
|
||||
|
|
@ -86,81 +93,3 @@ export const useFeedHeaderTitle = () => {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const useAddFeedToFeedList = (options?: {
|
||||
onSuccess?: () => void
|
||||
onError?: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation("settings")
|
||||
return useMutation({
|
||||
mutationFn: async (
|
||||
payload: { feedId: string; listId: string } | { feedIds: string[]; listId: string },
|
||||
) => {
|
||||
const feeds = await apiClient.lists.feeds.$post({
|
||||
json: payload,
|
||||
})
|
||||
|
||||
feeds.data.forEach((feed) => listActions.addFeedToFeedList(payload.listId, feed))
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("lists.feeds.add.success"))
|
||||
|
||||
options?.onSuccess?.()
|
||||
},
|
||||
async onError() {
|
||||
toast.error(t("lists.feeds.add.error"))
|
||||
options?.onError?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useRemoveFeedFromFeedList = (options?: {
|
||||
onSuccess: () => void
|
||||
onError: () => void
|
||||
}) => {
|
||||
const { t } = useTranslation("settings")
|
||||
return useMutation({
|
||||
mutationFn: async (payload: { feedId: string; listId: string }) => {
|
||||
listActions.removeFeedFromFeedList(payload.listId, payload.feedId)
|
||||
await apiClient.lists.feeds.$delete({
|
||||
json: {
|
||||
listId: payload.listId,
|
||||
feedId: payload.feedId,
|
||||
},
|
||||
})
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast.success(t("lists.feeds.delete.success"))
|
||||
options?.onSuccess?.()
|
||||
},
|
||||
async onError() {
|
||||
toast.error(t("lists.feeds.delete.error"))
|
||||
options?.onError?.()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export const useResetFeed = () => {
|
||||
const { t } = useTranslation()
|
||||
const toastIDRef = useRef<string | number | null>(null)
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (feedId: string) => {
|
||||
toastIDRef.current = toast.loading(t("sidebar.feed_actions.resetting_feed"))
|
||||
await apiClient.feeds.reset.$get({ query: { id: feedId } })
|
||||
},
|
||||
onSuccess: (_, feedId) => {
|
||||
entries.entries({ feedId }).invalidateRoot()
|
||||
toast.success(
|
||||
t("sidebar.feed_actions.reset_feed_success"),
|
||||
toastIDRef.current ? { id: toastIDRef.current } : undefined,
|
||||
)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(
|
||||
t("sidebar.feed_actions.reset_feed_error"),
|
||||
toastIDRef.current ? { id: toastIDRef.current } : undefined,
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
import type { FeedModel } from "@follow/models"
|
||||
import type { FeedModel, FeedOrListRespModel } from "@follow/models"
|
||||
|
||||
import type { useInboxStore } from "../inbox"
|
||||
import type { useListStore } from "../list"
|
||||
import type { useFeedStore } from "./store"
|
||||
import type { FeedQueryParams } from "./types"
|
||||
|
||||
type FeedState = ReturnType<typeof useFeedStore.getState>
|
||||
type ListState = ReturnType<typeof useListStore.getState>
|
||||
type InboxState = ReturnType<typeof useInboxStore.getState>
|
||||
|
||||
export const feedIconSelector = (feed: FeedModel) => {
|
||||
return {
|
||||
|
|
@ -11,3 +20,38 @@ export const feedIconSelector = (feed: FeedModel) => {
|
|||
siteUrl: feed.siteUrl,
|
||||
}
|
||||
}
|
||||
|
||||
export const feedByIdSelector = (feedId: Nullable<string>) => (state: FeedState) =>
|
||||
feedId ? state.feeds[feedId] : null
|
||||
|
||||
export const feedByIdWithTransformSelector =
|
||||
<T>(feedId: Nullable<string>, transform: (feed: FeedModel) => T) =>
|
||||
(state: FeedState) => {
|
||||
const feed = feedId ? state.feeds[feedId] : null
|
||||
return transform ? feed && transform(feed) : feed
|
||||
}
|
||||
|
||||
export const feedByIdOrUrlSelector = (feed: FeedQueryParams) => (state: FeedState) => {
|
||||
if (feed.id) {
|
||||
return state.feeds[feed.id]
|
||||
}
|
||||
if (feed.url) {
|
||||
return Object.values(state.feeds).find((f) => f.type === "feed" && f.url === feed.url) || null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const feedByIdSelectorWithTransform =
|
||||
<T>(feedId: Nullable<string>, selector: (feed: FeedOrListRespModel) => T) =>
|
||||
(state: FeedState) =>
|
||||
feedId && state.feeds[feedId] ? selector(state.feeds[feedId]) : null
|
||||
|
||||
export const listByIdSelectorWithTransform =
|
||||
<T>(listId: Nullable<string>, selector: (list: any) => T) =>
|
||||
(state: ListState) =>
|
||||
listId && state.lists[listId] ? selector(state.lists[listId]) : null
|
||||
|
||||
export const inboxByIdSelectorWithTransform =
|
||||
<T>(inboxId: Nullable<string>, selector: (inbox: any) => T) =>
|
||||
(state: InboxState) =>
|
||||
inboxId && state.inboxes[inboxId] ? selector(state.inboxes[inboxId]) : null
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { runTransactionInScope } from "~/database"
|
|||
import { apiClient } from "~/lib/api-fetch"
|
||||
import { InboxService } from "~/services/inbox"
|
||||
|
||||
import { createImmerSetter, createZustandStore } from "../utils/helper"
|
||||
import { createImmerSetter, createTransaction, createZustandStore } from "../utils/helper"
|
||||
import type { InboxState } from "./types"
|
||||
|
||||
export const useInboxStore = createZustandStore<InboxState>("inbox")(() => ({
|
||||
|
|
@ -12,7 +12,7 @@ export const useInboxStore = createZustandStore<InboxState>("inbox")(() => ({
|
|||
}))
|
||||
|
||||
const set = createImmerSetter(useInboxStore)
|
||||
|
||||
const get = useInboxStore.getState
|
||||
class InboxActionStatic {
|
||||
upsertMany(inboxes: InboxModel[]) {
|
||||
if (inboxes.length === 0) return
|
||||
|
|
@ -50,15 +50,20 @@ class InboxActionStatic {
|
|||
}
|
||||
|
||||
async deleteInbox(inboxId: string) {
|
||||
// TODO rollback
|
||||
this.clearByInboxId(inboxId)
|
||||
runTransactionInScope(() => InboxService.bulkDelete([inboxId]))
|
||||
|
||||
await apiClient.inboxes.$delete({
|
||||
json: {
|
||||
handle: inboxId,
|
||||
},
|
||||
const inbox = get().inboxes[inboxId]
|
||||
const tx = createTransaction(inbox)
|
||||
tx.execute(async () => {
|
||||
await apiClient.inboxes.$delete({
|
||||
json: {
|
||||
handle: inboxId,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
tx.optimistic(async () => this.clearByInboxId(inboxId))
|
||||
tx.persist(() => InboxService.bulkDelete([inboxId]))
|
||||
tx.rollback(async (inbox) => this.upsertMany([inbox]))
|
||||
await tx.run()
|
||||
}
|
||||
|
||||
async fetchOwnedInboxes() {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { FeedViewType } from "@follow/constants"
|
||||
import type { ListModel } from "@follow/models/types"
|
||||
import { useMemo } from "react"
|
||||
import { useCallback, useMemo } from "react"
|
||||
|
||||
import { useWhoami } from "~/atoms/user"
|
||||
|
||||
|
|
@ -10,7 +10,9 @@ export const useListById = (listId: Nullable<string>): ListModel | null =>
|
|||
useListStore((state) => (listId ? state.lists[listId] : null))
|
||||
|
||||
export const useListByView = (view: FeedViewType) => {
|
||||
return useListStore((state) => Object.values(state.lists).filter((list) => list.view === view))
|
||||
return useListStore(
|
||||
useCallback((state) => Object.values(state.lists).filter((list) => list.view === view), [view]),
|
||||
)
|
||||
}
|
||||
|
||||
export const useOwnedListByView = (view: FeedViewType) => {
|
||||
|
|
@ -24,7 +26,10 @@ export const useOwnedListByView = (view: FeedViewType) => {
|
|||
|
||||
export const useOwnedLists = () => {
|
||||
const whoami = useWhoami()
|
||||
return useListStore((state) =>
|
||||
Object.values(state.lists).filter((list) => list.ownerUserId === whoami?.id),
|
||||
return useListStore(
|
||||
useCallback(
|
||||
(state) => Object.values(state.lists).filter((list) => list.ownerUserId === whoami?.id),
|
||||
[whoami?.id],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import { ROUTE_FEED_IN_LIST } from "~/constants"
|
||||
|
||||
import { subscriptionCategoryExistSelector } from "./selector"
|
||||
import { useSubscriptionStore } from "./store"
|
||||
|
||||
const get = useSubscriptionStore.getState
|
||||
export const getSubscriptionByFeedId = (feedId: FeedId) => {
|
||||
const state = get()
|
||||
return state.data[feedId]
|
||||
}
|
||||
|
||||
export const isListSubscription = (feedId?: FeedId) => {
|
||||
if (!feedId) return false
|
||||
const subscription = getSubscriptionByFeedId(feedId.replace(ROUTE_FEED_IN_LIST, ""))
|
||||
if (!subscription) return false
|
||||
return "listId" in subscription && !!subscription.listId
|
||||
}
|
||||
|
||||
export const subscriptionCategoryExist = (name: string) =>
|
||||
subscriptionCategoryExistSelector(name)(get())
|
||||
|
|
@ -1,70 +1,75 @@
|
|||
import { FeedViewType } from "@follow/constants"
|
||||
import type { FeedViewType } from "@follow/constants"
|
||||
import { useCallback } from "react"
|
||||
|
||||
import { FEED_COLLECTION_LIST, ROUTE_FEED_IN_FOLDER } from "~/constants"
|
||||
|
||||
import { subscriptionCategoryExistSelector, useSubscriptionStore } from "../subscription"
|
||||
import { useFeedStore } from "../feed"
|
||||
import {
|
||||
categoryOpenStateByViewSelector,
|
||||
feedIdByViewSelector,
|
||||
feedSubscriptionCountSelector,
|
||||
folderFeedsByFeedIdSelector,
|
||||
inboxSubscriptionCountSelector,
|
||||
listSubscriptionCountSelector,
|
||||
subscriptionByFeedIdSelector,
|
||||
subscriptionByViewSelector,
|
||||
subscriptionCategoryExistSelector,
|
||||
} from "./selector"
|
||||
import { useSubscriptionStore } from "./store"
|
||||
|
||||
type FeedId = string
|
||||
|
||||
export const useFeedIdByView = (view: FeedViewType) =>
|
||||
useSubscriptionStore((state) => state.feedIdByView[view] || [])
|
||||
useSubscriptionStore(useCallback((state) => feedIdByViewSelector(view)(state), [view]))
|
||||
|
||||
export const useCategoryOpenStateByView = (view: FeedViewType) =>
|
||||
useSubscriptionStore((state) => state.categoryOpenStateByView[view])
|
||||
useSubscriptionStore(useCallback((state) => categoryOpenStateByViewSelector(view)(state), [view]))
|
||||
|
||||
export const useSubscriptionByView = (view: FeedViewType) =>
|
||||
useSubscriptionStore((state) => state.feedIdByView[view].map((id) => state.data[id]))
|
||||
useSubscriptionStore(useCallback((state) => subscriptionByViewSelector(view)(state), [view]))
|
||||
|
||||
export const useSubscriptionByFeedId = (feedId: FeedId) =>
|
||||
useSubscriptionStore((state) => state.data[feedId])
|
||||
useSubscriptionStore(
|
||||
useCallback((state) => subscriptionByFeedIdSelector(feedId)(state), [feedId]),
|
||||
)
|
||||
|
||||
export const useFolderFeedsByFeedId = ({ feedId, view }: { feedId?: string; view: FeedViewType }) =>
|
||||
useSubscriptionStore((state): string[] | null => {
|
||||
if (typeof feedId !== "string") return null
|
||||
if (feedId === FEED_COLLECTION_LIST) {
|
||||
return [feedId]
|
||||
}
|
||||
|
||||
if (!feedId.startsWith(ROUTE_FEED_IN_FOLDER)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const folderName = feedId.replace(ROUTE_FEED_IN_FOLDER, "")
|
||||
const feedIds: string[] = []
|
||||
for (const feedId in state.data) {
|
||||
const subscription = state.data[feedId]
|
||||
if (
|
||||
subscription.view === view &&
|
||||
(subscription.category === folderName || subscription.defaultCategory === folderName)
|
||||
) {
|
||||
feedIds.push(feedId)
|
||||
}
|
||||
}
|
||||
return feedIds
|
||||
})
|
||||
|
||||
export const useListSubscriptionCount = () =>
|
||||
useSubscriptionStore(
|
||||
(state) =>
|
||||
Object.values(state.data).filter((s) => !!s.listId && state.subscriptionIdSet.has(s.listId))
|
||||
.length,
|
||||
useCallback((state) => folderFeedsByFeedIdSelector({ feedId, view })(state), [feedId, view]),
|
||||
)
|
||||
|
||||
export const useInboxSubscriptionCount = () =>
|
||||
useSubscriptionStore(
|
||||
(state) =>
|
||||
Object.values(state.data).filter(
|
||||
(s) => !!s.inboxId && state.feedIdByView[FeedViewType.Articles].includes(s.inboxId),
|
||||
).length,
|
||||
)
|
||||
export const useListSubscriptionCount = () => useSubscriptionStore(listSubscriptionCountSelector)
|
||||
|
||||
export const useFeedSubscriptionCount = () =>
|
||||
useSubscriptionStore(
|
||||
(state) =>
|
||||
Object.values(state.data).filter(
|
||||
// FIXME: Backend data compatibility
|
||||
(s) => !!s.feedId && !s.listId && !s.inboxId && state.subscriptionIdSet.has(s.feedId),
|
||||
).length,
|
||||
)
|
||||
export const useInboxSubscriptionCount = () => useSubscriptionStore(inboxSubscriptionCountSelector)
|
||||
|
||||
export const useFeedSubscriptionCount = () => useSubscriptionStore(feedSubscriptionCountSelector)
|
||||
|
||||
export const useSubscriptionCategoryExist = (name: string) =>
|
||||
useSubscriptionStore(subscriptionCategoryExistSelector(name))
|
||||
useSubscriptionStore(
|
||||
useCallback((state) => subscriptionCategoryExistSelector(name)(state), [name]),
|
||||
)
|
||||
|
||||
export const useAllFeeds = () => {
|
||||
const feedTitleMap = useFeedStore(
|
||||
useCallback((store) => {
|
||||
return Object.fromEntries(Object.entries(store.feeds).map(([id, feed]) => [id, feed.title]))
|
||||
}, []),
|
||||
)
|
||||
return useSubscriptionStore(
|
||||
useCallback(
|
||||
(store) => {
|
||||
const feedInfo = [] as { title: string; id: string }[]
|
||||
|
||||
const allSubscriptions = Object.values(store.feedIdByView).flat()
|
||||
|
||||
for (const feedId of allSubscriptions) {
|
||||
const subscription = store.data[feedId]
|
||||
const feed = feedTitleMap[feedId]
|
||||
if (feed) {
|
||||
feedInfo.push({ title: subscription.title || feed || "", id: feedId })
|
||||
}
|
||||
}
|
||||
return feedInfo
|
||||
},
|
||||
[feedTitleMap],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
export * from "./getters"
|
||||
export * from "./hooks"
|
||||
export * from "./selector"
|
||||
export * from "./store"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,62 @@
|
|||
import { FeedViewType } from "@follow/constants"
|
||||
|
||||
import { FEED_COLLECTION_LIST, ROUTE_FEED_IN_FOLDER } from "~/constants"
|
||||
|
||||
import type { useSubscriptionStore } from "./store"
|
||||
|
||||
type State = ReturnType<typeof useSubscriptionStore.getState>
|
||||
|
||||
export const subscriptionCategoryExistSelector = (name: string) => (state: State) =>
|
||||
state.categories.has(name)
|
||||
|
||||
export const feedSubscriptionCountSelector = (state: State) =>
|
||||
Object.values(state.data).filter(
|
||||
// FIXME: Backend data compatibility
|
||||
(s) => !!s.feedId && !s.listId && !s.inboxId && state.subscriptionIdSet.has(s.feedId),
|
||||
).length
|
||||
|
||||
export const feedIdByViewSelector = (view: FeedViewType) => (state: State) =>
|
||||
state.feedIdByView[view]
|
||||
|
||||
export const categoryOpenStateByViewSelector = (view: FeedViewType) => (state: State) =>
|
||||
state.categoryOpenStateByView[view]
|
||||
|
||||
export const subscriptionByViewSelector = (view: FeedViewType) => (state: State) =>
|
||||
state.feedIdByView[view].map((id) => state.data[id])
|
||||
|
||||
export const subscriptionByFeedIdSelector = (feedId: string) => (state: State) => state.data[feedId]
|
||||
|
||||
export const folderFeedsByFeedIdSelector =
|
||||
({ feedId, view }: { feedId?: string; view: FeedViewType }) =>
|
||||
(state: State): string[] | null => {
|
||||
if (typeof feedId !== "string") return null
|
||||
if (feedId === FEED_COLLECTION_LIST) {
|
||||
return [feedId]
|
||||
}
|
||||
|
||||
if (!feedId.startsWith(ROUTE_FEED_IN_FOLDER)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const folderName = feedId.replace(ROUTE_FEED_IN_FOLDER, "")
|
||||
const feedIds: string[] = []
|
||||
for (const feedId in state.data) {
|
||||
const subscription = state.data[feedId]
|
||||
if (
|
||||
subscription.view === view &&
|
||||
(subscription.category === folderName || subscription.defaultCategory === folderName)
|
||||
) {
|
||||
feedIds.push(feedId)
|
||||
}
|
||||
}
|
||||
return feedIds
|
||||
}
|
||||
|
||||
export const listSubscriptionCountSelector = (state: State) =>
|
||||
Object.values(state.data).filter((s) => !!s.listId && state.subscriptionIdSet.has(s.listId))
|
||||
.length
|
||||
|
||||
export const inboxSubscriptionCountSelector = (state: State) =>
|
||||
Object.values(state.data).filter(
|
||||
(s) => !!s.inboxId && state.feedIdByView[FeedViewType.Articles].includes(s.inboxId),
|
||||
).length
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import { omit } from "lodash-es"
|
|||
import { parse } from "tldts"
|
||||
|
||||
import { whoami } from "~/atoms/user"
|
||||
import { ROUTE_FEED_IN_LIST } from "~/constants"
|
||||
import { runTransactionInScope } from "~/database"
|
||||
import { apiClient } from "~/lib/api-fetch"
|
||||
import { queryClient } from "~/lib/query-client"
|
||||
|
|
@ -24,7 +23,6 @@ import { inboxActions } from "../inbox"
|
|||
import { listActions } from "../list"
|
||||
import { feedUnreadActions } from "../unread"
|
||||
import { createImmerSetter, createTransaction, createZustandStore } from "../utils/helper"
|
||||
import { subscriptionCategoryExistSelector } from "./selector"
|
||||
|
||||
export type SubscriptionFlatModel = Omit<SubscriptionModel, "feeds"> & {
|
||||
defaultCategory?: string
|
||||
|
|
@ -689,18 +687,3 @@ class SubscriptionActions {
|
|||
}
|
||||
|
||||
export const subscriptionActions = new SubscriptionActions()
|
||||
|
||||
export const getSubscriptionByFeedId = (feedId: FeedId) => {
|
||||
const state = get()
|
||||
return state.data[feedId]
|
||||
}
|
||||
|
||||
export const isListSubscription = (feedId?: FeedId) => {
|
||||
if (!feedId) return false
|
||||
const subscription = getSubscriptionByFeedId(feedId.replace(ROUTE_FEED_IN_LIST, ""))
|
||||
if (!subscription) return false
|
||||
return "listId" in subscription && !!subscription.listId
|
||||
}
|
||||
|
||||
export const subscriptionCategoryExist = (name: string) =>
|
||||
subscriptionCategoryExistSelector(name)(get())
|
||||
|
|
|
|||
Loading…
Reference in New Issue