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:
Innei 2024-11-13 17:21:00 +08:00 committed by GitHub
parent 2933e5b328
commit 50fd58f824
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 599 additions and 438 deletions

View File

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

View File

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

View File

@ -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

View File

@ -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>

View File

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

View File

@ -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()

View File

@ -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")

View File

@ -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>

View File

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

View File

@ -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])

View File

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

View File

@ -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

View File

@ -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() {

View File

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

View File

@ -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())

View File

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

View File

@ -1,3 +1,4 @@
export * from "./getters"
export * from "./hooks"
export * from "./selector"
export * from "./store"

View File

@ -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

View File

@ -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())