refactor(store): detach the inbox from the feed store, reduce re-rendering (#1228)

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2024-10-29 23:21:13 +08:00 committed by GitHub
parent 5065c8e52d
commit bc1f39bf67
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 255 additions and 160 deletions

View File

@ -95,6 +95,7 @@ export function FeedPreview(props: {
feeds: feedData,
read: true,
feedId: feedData.id!,
inboxId: "",
}}
entryId={entry.id}
/>

View File

@ -41,7 +41,8 @@ import StarAnimationUri from "~/lottie/star.lottie?url"
import { useTipModal } from "~/modules/wallet/hooks"
import type { FlatEntryModel } from "~/store/entry"
import { entryActions } from "~/store/entry"
import { useFeedById } from "~/store/feed"
import { getFeedById, useFeedById } from "~/store/feed"
import { useInboxById } from "~/store/inbox"
import { navigateEntry } from "./useNavigateEntry"
@ -110,23 +111,27 @@ export const useUnCollect = (entry: Nullable<CombinedEntryModel>) => {
export const useRead = () =>
useMutation({
mutationFn: async (entry: Nullable<CombinedEntryModel>) =>
entry &&
entryActions.markRead({
feedId: entry.feeds.id,
mutationFn: async (entry: Nullable<CombinedEntryModel>) => {
const relatedId = entry?.feeds?.id || entry?.inboxes?.id
if (!relatedId) return
return entryActions.markRead({
feedId: relatedId,
entryId: entry.entries.id,
read: true,
}),
})
},
})
export const useUnread = () =>
useMutation({
mutationFn: async (entry: Nullable<CombinedEntryModel>) =>
entry &&
entryActions.markRead({
feedId: entry.feeds.id,
mutationFn: async (entry: Nullable<CombinedEntryModel>) => {
const relatedId = entry?.feeds?.id || entry?.inboxes?.id
if (!relatedId) return
return entryActions.markRead({
feedId: relatedId,
entryId: entry.entries.id,
read: false,
}),
})
},
})
export const useDeleteInboxEntry = () => {
@ -155,21 +160,31 @@ export const useEntryActions = ({
}) => {
const { t } = useTranslation()
const feed = useFeedById(entry?.feedId)
const isInbox = feed?.type === "inbox"
const feed = useFeedById(entry?.feedId, (feed) => {
return {
type: feed.type,
ownerUserId: feed.ownerUserId,
id: feed.id,
}
})
const inbox = useInboxById(entry?.inboxId)
const isInbox = !!inbox
const populatedEntry = useMemo(() => {
if (!entry) return null
if (!feed) return null
if (!feed?.id && !inbox?.id) return null
return {
...entry,
feeds: feed!,
feeds: feed ? getFeedById(feed.id) : undefined,
inboxes: inbox,
} as CombinedEntryModel
}, [entry, feed])
}, [entry, feed, inbox])
const openTipModal = useTipModal({
userId: populatedEntry?.feeds.ownerUserId ?? undefined,
feedId: populatedEntry?.feeds.id ?? undefined,
userId: populatedEntry?.feeds?.ownerUserId ?? undefined,
feedId: populatedEntry?.feeds?.id ?? undefined,
entryId: populatedEntry?.entries.id ?? undefined,
})

View File

@ -60,8 +60,15 @@ export const useFeedActions = ({
type?: "feedList" | "entryList"
}) => {
const { t } = useTranslation()
const feed = useFeedById(feedId)
const isInbox = feed?.type === "inbox"
const feed = useFeedById(feedId, (feed) => {
return {
type: feed.type,
ownerUserId: feed.ownerUserId,
id: feed.id,
}
})
const inbox = useInboxById(feedId)
const isInbox = !!inbox
const subscription = useSubscriptionByFeedId(feedId)
const { present } = useModalStack()
const deleteSubscription = useDeleteSubscription({})
@ -81,7 +88,8 @@ export const useFeedActions = ({
const isMultipleSelection = feedIds && feedIds.length > 0
const items = useMemo(() => {
if (!feed) return []
const related = feed || inbox
if (!related) return []
const items: NullableNativeMenuItem[] = [
{
@ -95,9 +103,9 @@ export const useFeedActions = ({
}),
supportMultipleSelection: true,
},
!feed.ownerUserId &&
!!isBizId(feed.id) &&
feed.type === "feed" && {
!related.ownerUserId &&
!!isBizId(related.id) &&
related.type === "feed" && {
type: "text" as const,
label: isEntryList
? t("sidebar.feed_actions.claim_feed")
@ -107,7 +115,7 @@ export const useFeedActions = ({
claimFeed()
},
},
...(feed.ownerUserId === whoami()?.id
...(related.ownerUserId === whoami()?.id
? [
{
type: "text" as const,
@ -285,12 +293,13 @@ export const useFeedActions = ({
return items
}, [
feed,
inbox,
t,
isEntryList,
isInbox,
listByView,
feedId,
isMultipleSelection,
feedId,
feedIds,
claimFeed,
openBoostModal,

View File

@ -7,9 +7,7 @@ import { FeedIcon } from "~/modules/feed/feed-icon"
import type { EntryItemStatelessProps, UniversalItemProps } from "../types"
export function NotificationItem({ entryId, entryPreview, translation }: UniversalItemProps) {
return (
<ListItem entryId={entryId} entryPreview={entryPreview} translation={translation} withFollow />
)
return <ListItem entryId={entryId} entryPreview={entryPreview} translation={translation} />
}
export function NotificationItemStateLess({ entry, feed }: EntryItemStatelessProps) {

View File

@ -52,6 +52,7 @@ function EntryItemImpl({ entry, view }: { entry: FlatEntryModel; view?: number }
export const EntryItem: FC<EntryItemProps> = memo(({ entryId, view }) => {
const entry = useEntry(entryId)
if (!entry) return <ReactVirtuosoItemPlaceholder />
return <EntryItemImpl entry={entry} view={view} />
})

View File

@ -32,7 +32,7 @@ export const EntryItemWrapper: FC<
})
const { items: feedItems } = useFeedActions({
feedId: entry.feedId,
feedId: entry.feedId || entry.inboxId,
view,
type: "entryList",
})

View File

@ -1,7 +1,4 @@
import { FollowIcon } from "@follow/components/icons/follow.jsx"
import { Button } from "@follow/components/ui/button/index.js"
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js"
import { UrlBuilder } from "@follow/utils/url-builder"
import { cn, isSafari } from "@follow/utils/utils"
import { useDebounceCallback } from "usehooks-ts"
@ -9,17 +6,15 @@ import { AudioPlayer, useAudioPlayerAtomSelector } from "~/atoms/player"
import { useUISettingKey } from "~/atoms/settings/ui"
import { RelativeTime } from "~/components/ui/datetime"
import { Media } from "~/components/ui/media"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { FEED_COLLECTION_LIST } from "~/constants"
import { useAsRead } from "~/hooks/biz/useAsRead"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { FeedForm } from "~/modules/discover/feed-form"
import { EntryTranslation } from "~/modules/entry-column/translation"
import { FeedIcon } from "~/modules/feed/feed-icon"
import { Queries } from "~/queries"
import { useEntry } from "~/store/entry/hooks"
import { getPreferredTitle, useFeedById } from "~/store/feed"
import { useSubscriptionStore } from "~/store/subscription"
import { useInboxById } from "~/store/inbox"
import { ReactVirtuosoItemPlaceholder } from "../../../components/ui/placeholder"
import { StarIcon } from "../star-icon"
@ -31,11 +26,9 @@ export function ListItem({
translation,
withDetails,
withAudio,
withFollow,
}: UniversalItemProps & {
withDetails?: boolean
withAudio?: boolean
withFollow?: boolean
}) {
const entry = useEntry(entryId) || entryPreview
@ -43,11 +36,23 @@ export function ListItem({
const inInCollection = useRouteParamsSelector((s) => s.feedId === FEED_COLLECTION_LIST)
const feed = useFeedById(entry?.feedId) || entryPreview?.feeds
const feed =
useFeedById(entry?.feedId, (feed) => {
return {
type: feed.type,
ownerUserId: feed.ownerUserId,
id: feed.id,
title: feed.title,
url: (feed as any).url || "",
image: feed.image,
}
}) || entryPreview?.feeds
const inbox = useInboxById(entry?.inboxId)
const handlePrefetchEntry = useDebounceCallback(
() => {
feed?.type === "inbox"
inbox
? Queries.entries.byInboxId(entryId).prefetch()
: Queries.entries.byId(entryId).prefetch()
},
@ -55,22 +60,16 @@ export function ListItem({
{ leading: false },
)
const isSubscription =
withFollow && entry?.entries.url?.startsWith(UrlBuilder.shareFeed(entry.feedId))
const feedId = isSubscription
? entry?.entries.url?.slice(UrlBuilder.shareFeed(entry.feedId).length)
: undefined
const isFollowed = !!useSubscriptionStore((state) => feedId && state.data[feedId])
const { present } = useModalStack()
const settingWideMode = useUISettingKey("wideMode")
// NOTE: prevent 0 height element, react virtuoso will not stop render any more
if (!entry || !feed) return <ReactVirtuosoItemPlaceholder />
if (!entry || !(feed || inbox)) return <ReactVirtuosoItemPlaceholder />
const displayTime = inInCollection ? entry.collections?.createdAt : entry.entries.publishedAt
const envIsSafari = isSafari()
const related = feed || inbox
return (
<div
onMouseEnter={handlePrefetchEntry}
@ -82,7 +81,7 @@ export function ListItem({
settingWideMode ? "py-3" : "py-4",
)}
>
{!withAudio && <FeedIcon feed={feed} fallback entry={entry.entries} />}
{!withAudio && <FeedIcon feed={related} fallback entry={entry.entries} />}
<div
className={cn(
"-mt-0.5 flex-1 text-sm leading-tight",
@ -99,7 +98,7 @@ export function ListItem({
)}
>
<EllipsisHorizontalTextWithTooltip className="truncate">
{getPreferredTitle(feed, entry.entries)}
{getPreferredTitle(related, entry.entries)}
</EllipsisHorizontalTextWithTooltip>
<span>·</span>
<span className="shrink-0">{!!displayTime && <RelativeTime date={displayTime} />}</span>
@ -149,28 +148,6 @@ export function ListItem({
)}
</div>
{/* TODO remove This only share page needed */}
{feedId && !isFollowed && (
<Button
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
present({
title: `${APP_NAME}`,
clickOutsideToDismiss: true,
content: ({ dismiss }) => <FeedForm asWidget id={feedId} onSuccess={dismiss} />,
})
}}
variant="outline"
className="h-8"
>
<>
<FollowIcon className="mr-1 size-3" />
{APP_NAME}
</>
</Button>
)}
{withAudio && entry.entries?.attachments?.[0].url && (
<AudioCover
entryId={entryId}
@ -182,7 +159,7 @@ export function ListItem({
feedIcon={
<FeedIcon
fallback={false}
feed={feed}
feed={feed || inbox}
entry={entry.entries}
size={settingWideMode ? 65 : 80}
className="m-0 rounded"

View File

@ -11,6 +11,7 @@ export type UniversalItemProps = {
entryPreview?: CombinedEntryModel & {
feeds: FeedOrListRespModel
feedId: string
inboxId: string
}
translation?: {
title?: string

View File

@ -8,6 +8,7 @@ import { FeedIcon } from "~/modules/feed/feed-icon"
import { Queries } from "~/queries"
import { useEntry, useEntryReadHistory } from "~/store/entry"
import { getPreferredTitle, useFeedById } from "~/store/feed"
import { useInboxById } from "~/store/inbox"
import { EntryTranslation } from "../../entry-column/translation"
@ -28,10 +29,11 @@ export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
const user = useWhoami()
const entry = useEntry(entryId)
const feed = useFeedById(entry?.feedId)
const inbox = useInboxById(entry?.inboxId)
const entryHistory = useEntryReadHistory(entryId)
const populatedFullHref = useMemo(() => {
if (feed?.type === "inbox") return entry?.entries.authorUrl
if (inbox) return entry?.entries.authorUrl
const href = entry?.entries.url
if (!href) return "#"
@ -39,7 +41,7 @@ export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
const feedSiteUrl = feed?.type === "feed" ? feed.siteUrl : null
if (href.startsWith("/") && feedSiteUrl) return safeUrl(href, feedSiteUrl)
return href
}, [entry?.entries.authorUrl, entry?.entries.url, feed])
}, [entry?.entries.authorUrl, entry?.entries.url, feed?.siteUrl, feed?.type, inbox])
const translation = useAuthQuery(
Queries.ai.translation({
@ -61,10 +63,10 @@ export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
return compact ? (
<div className="-mx-6 flex cursor-button items-center gap-2 rounded-lg p-6 transition-colors @sm:-mx-3 @sm:p-3">
<FeedIcon fallback feed={feed} entry={entry.entries} size={50} />
<FeedIcon fallback feed={feed || inbox} entry={entry.entries} size={50} />
<div className="leading-6">
<div className="flex items-center gap-1 text-base font-semibold">
<span>{entry.entries.author || feed?.title}</span>
<span>{entry.entries.author || feed?.title || inbox?.title}</span>
</div>
<div className="text-zinc-500">
<RelativeTime date={entry.entries.publishedAt} />
@ -86,7 +88,7 @@ export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
/>
</div>
<div className="mt-2 text-[13px] font-medium text-zinc-500">
{getPreferredTitle(feed, entry.entries)}
{getPreferredTitle(feed || inbox, entry.entries)}
</div>
<div className="flex items-center gap-2 text-[13px] text-zinc-500">
{entry.entries.publishedAt && new Date(entry.entries.publishedAt).toLocaleString()}

View File

@ -40,6 +40,7 @@ import {
import { Queries } from "~/queries"
import { useEntry } from "~/store/entry"
import { useFeedById } from "~/store/feed"
import { useInboxById } from "~/store/inbox"
import { EntryContentHTMLRenderer } from "../renderer/html"
import {
@ -93,9 +94,10 @@ export const EntryContentRender: Component<{
const feed = useFeedById(entry?.feedId) as FeedModel | InboxModel
const readerRenderInlineStyle = useUISettingKey("readerRenderInlineStyle")
const inbox = useInboxById(entry?.inboxId, (inbox) => inbox !== null)
const { error, data, isPending } = useAuthQuery(
feed?.type === "inbox" ? Queries.entries.byInboxId(entryId) : Queries.entries.byId(entryId),
inbox ? Queries.entries.byInboxId(entryId) : Queries.entries.byId(entryId),
{
staleTime: 300_000,
},
@ -186,7 +188,7 @@ export const EntryContentRender: Component<{
})
}
const isInbox = feed?.type === "inbox"
const isInbox = !!inbox
return (
<>
@ -266,7 +268,9 @@ export const EntryContentRender: Component<{
{!content && (
<div className="center mt-16 min-w-0">
{isPending ? (
<EntryContentLoading icon={!isInbox ? feed?.siteUrl! : undefined} />
<EntryContentLoading
icon={!isInbox ? (feed as FeedModel)?.siteUrl! : undefined}
/>
) : error ? (
<div className="center flex min-w-0 flex-col gap-2">
<i className="i-mgc-close-cute-re text-3xl text-red-500" />
@ -310,10 +314,12 @@ const TitleMetaHandler: Component<{
const {
entries: { title: entryTitle },
feedId,
inboxId,
} = useEntry(entryId)!
const { title: feedTitle } = useFeedById(feedId)!
const feed = useFeedById(feedId)
const inbox = useInboxById(inboxId)
const feedTitle = feed?.title || inbox?.title
const atTop = useIsSoFWrappedElement()
useEffect(() => {
setEntryContentScrollToTop(true)

View File

@ -24,6 +24,7 @@ import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { FeedIcon } from "~/modules/feed/feed-icon"
import { useEntry } from "~/store/entry"
import { useFeedById } from "~/store/feed"
import { useInboxById } from "~/store/inbox"
import { useListById } from "~/store/list"
const handleClickPlay = () => {
@ -119,6 +120,8 @@ const CornerPlayerImpl = () => {
})
}, [entry, feed])
const isInbox = useInboxById(entry?.feedId, (inbox) => inbox !== null)
const navigateToEntry = useNavigateEntry()
usePlayerTracker()
@ -127,9 +130,9 @@ const CornerPlayerImpl = () => {
const options: NavigateEntryOptions = {
entryId: entry.entries.id,
}
if (feed?.type === "inbox") {
if (isInbox) {
Object.assign(options, {
inboxId: feed.id,
inboxId: entry?.feedId,
view: FeedViewType.Articles,
})
} else if (list) {

View File

@ -43,7 +43,18 @@ const FeedItemImpl = ({ view, feedId, className }: FeedItemProps) => {
const { t } = useTranslation()
const subscription = useSubscriptionByFeedId(feedId)
const navigate = useNavigateEntry()
const feed = useFeedById(feedId)
const feed = useFeedById(feedId, (feed) => {
return {
type: feed.type,
id: feed.id,
title: feed.title,
errorAt: feed.errorAt,
errorMessage: feed.errorMessage,
url: feed.url,
image: feed.image,
}
})
const [selectedFeedIds, setSelectedFeedIds] = useSelectedFeedIds()
const handleClick: React.MouseEventHandler<HTMLDivElement> = useCallback(

View File

@ -64,6 +64,14 @@ const FallbackableImage = forwardRef<
/>
)
})
type FeedIconFeed =
| (Pick<FeedModel, "ownerUserId" | "id" | "title" | "url" | "image"> & {
type: FeedOrListRespModel["type"]
})
| FeedOrListRespModel
type FeedIconEntry = Pick<CombinedEntryModel["entries"], "media" | "authorAvatar">
export function FeedIcon({
feed,
entry,
@ -74,8 +82,8 @@ export function FeedIcon({
siteUrl,
useMedia,
}: {
feed?: FeedOrListRespModel | null
entry?: CombinedEntryModel["entries"]
feed?: FeedIconFeed | null
entry?: FeedIconEntry | null
fallbackUrl?: string
className?: string
size?: number

View File

@ -46,7 +46,7 @@ export const entries = {
rootKey: ["entries"],
}),
byInboxId: (id: string) =>
defineQuery(["entry", id], async () => entryActions.fetchInboxEntryById(id), {
defineQuery(["entry", "inbox", id], async () => entryActions.fetchInboxEntryById(id), {
rootKey: ["entries"],
}),
preview: (id: string) =>

View File

@ -21,19 +21,16 @@ class EntryServiceStatic extends BaseService<EntryModel> implements Hydable {
// @ts-expect-error
override async upsertMany(data: EntryModel[], entryFeedMap: Record<string, string>) {
const renewList = [] as { type: "entry"; id: string }[]
const nextData = [] as (EntryModel & { feedId: string })[]
const nextData = [] as (EntryModel & { feedId?: string; inboxId?: string })[]
for (const item of data) {
const feedId = entryFeedMap[item.id]
for (const entry of data) {
const feedId = entryFeedMap[entry.id]
if (!feedId) {
console.error("EntryService.upsertMany: feedId not found", item)
console.error("EntryService.upsertMany: feedId not found", entry)
continue
}
renewList.push({ type: "entry", id: item.id })
nextData.push({
...item,
feedId,
})
renewList.push({ type: "entry", id: entry.id })
nextData.push(Object.assign({}, entry, feedId ? { feedId } : { inboxId: feedId }))
}
CleanerService.reset(renewList)
@ -62,7 +59,7 @@ class EntryServiceStatic extends BaseService<EntryModel> implements Hydable {
}
override async findAll() {
return super.findAll() as Promise<(EntryModel & { feedId: string })[]>
return super.findAll() as Promise<(EntryModel & { feedId: string; inboxId: string })[]>
}
bulkStoreReadStatus(record: Record<string, boolean>) {
@ -121,6 +118,7 @@ class EntryServiceStatic extends BaseService<EntryModel> implements Hydable {
collections: collections[entry.id] as {
createdAt: string
},
inboxId: entry.inboxId,
})
}
entryActions.hydrate(storeValue)

View File

@ -3,6 +3,7 @@ import type {
EntryModel,
FeedModel,
FeedOrListRespModel,
InboxModel,
} from "@follow/models/types"
import type { EntryReadHistoriesModel } from "@follow/shared/hono"
import { omitObjectUndefinedValue } from "@follow/utils/utils"
@ -82,11 +83,12 @@ class EntryActions {
},
})
if (data) {
this.upsertMany([
// patch data, should omit `read` because the network race condition or server cache
omit(data, "read") as any,
])
inboxActions.upsertMany([data.feeds])
// patch data, should omit `read` because the network race condition or server cache
const nextData = omit(data, "feeds", "read")
// Data compatibility
if (data.feeds && !(data as any).inboxes) (nextData as any).inboxes = data.feeds
this.upsertMany([nextData as any])
inboxActions.upsertMany([(nextData as any).inboxes])
}
return data
@ -112,14 +114,26 @@ class EntryActions {
isArchived?: boolean
}) {
const data = inboxId
? await apiClient.entries.inbox.$post({
json: {
publishedAfter: pageParam,
limit,
inboxId: `${inboxId}`,
read,
},
})
? await apiClient.entries.inbox
.$post({
json: {
publishedAfter: pageParam,
limit,
inboxId: `${inboxId}`,
read,
},
})
.then((res) => {
return {
...res,
data: res.data?.map(({ feeds, ...d }) => {
return {
...d,
inboxes: feeds,
}
}),
}
})
: await apiClient.entries.$post({
json: {
publishedAfter: pageParam,
@ -220,6 +234,7 @@ class EntryActions {
upsertMany(data: CombinedEntryModel[]) {
const feeds = [] as FeedOrListRespModel[]
const entries = [] as EntryModel[]
const inboxes = [] as InboxModel[]
const entry2Read = {} as Record<string, boolean>
const entryFeedMap = {} as Record<string, string>
const entryCollection = {} as Record<string, any>
@ -233,38 +248,74 @@ class EntryActions {
item.entries,
)
if (!draft.entries[item.feeds.id]) {
draft.entries[item.feeds.id] = []
// Is related to feed
if (item.feeds) {
if (!draft.entries[item.feeds.id]) {
draft.entries[item.feeds.id] = []
}
if (!draft.internal_feedId2entryIdSet[item.feeds.id]) {
draft.internal_feedId2entryIdSet[item.feeds.id] = new Set()
}
if (!draft.internal_feedId2entryIdSet[item.feeds.id].has(item.entries.id)) {
draft.entries[item.feeds.id].push(item.entries.id)
draft.internal_feedId2entryIdSet[item.feeds.id].add(item.entries.id)
}
draft.flatMapEntries[item.entries.id] = merge(
draft.flatMapEntries[item.entries.id] || {},
{
feedId: item.feeds.id,
entries: mergedEntry,
},
omit(item, "feeds"),
)
// Push feed
feeds.push(item.feeds)
// Push entryFeedMap
entryFeedMap[item.entries.id] = item.feeds.id
}
if (!draft.internal_feedId2entryIdSet[item.feeds.id]) {
draft.internal_feedId2entryIdSet[item.feeds.id] = new Set()
// Is related to inbox
if (item.inboxes) {
const inboxId = `inbox-${item.inboxes.id}`
if (!draft.entries[inboxId]) {
draft.entries[inboxId] = []
}
if (!draft.internal_feedId2entryIdSet[inboxId]) {
draft.internal_feedId2entryIdSet[inboxId] = new Set()
}
if (!draft.internal_feedId2entryIdSet[inboxId].has(item.entries.id)) {
draft.entries[inboxId].push(item.entries.id)
draft.internal_feedId2entryIdSet[inboxId].add(item.entries.id)
}
draft.flatMapEntries[item.entries.id] = merge(
draft.flatMapEntries[item.entries.id] || {},
{
inboxId: item.inboxes.id,
entries: mergedEntry,
},
omit(item, "inboxes"),
)
// Push entryFeedMap
entryFeedMap[item.entries.id] = inboxId
inboxes.push(item.inboxes)
}
if (!draft.internal_feedId2entryIdSet[item.feeds.id].has(item.entries.id)) {
draft.entries[item.feeds.id].push(item.entries.id)
draft.internal_feedId2entryIdSet[item.feeds.id].add(item.entries.id)
}
draft.flatMapEntries[item.entries.id] = merge(
draft.flatMapEntries[item.entries.id] || {},
{
feedId: item.feeds.id,
entries: mergedEntry,
},
omit(item, "feeds"),
)
// Push feed
feeds.push(item.feeds)
// Push entry
entries.push(mergedEntry)
// Push entry2Read
if (!isNil(item.read)) {
entry2Read[item.entries.id] = item.read
}
// Push entryFeedMap
entryFeedMap[item.entries.id] = item.feeds.id
// Push entryCollection
if ("collections" in item) {
entryCollection[item.entries.id] = item.collections
@ -291,6 +342,7 @@ class EntryActions {
)
// Insert to feed store
feedActions.upsertMany(feeds as FeedModel[])
inboxActions.upsertMany(inboxes)
const newStarIds = new Set(get().starIds)
for (const entryId in entryCollection) {
newStarIds.add(entryId)

View File

@ -7,9 +7,8 @@ type EntryId = string
type EntriesIdTable = Record<FeedId, EntryId[]>
export type FlatEntryModel = Omit<CombinedEntryModel, "feeds"> & {
feedId: FeedId
view?: number
}
} & { feedId: FeedId; inboxId: string }
export interface EntryState {
/**
* A map of feedId to entryIds

View File

@ -1,5 +1,5 @@
import { views } from "@follow/constants"
import type { FeedOrListRespModel, InboxModel, ListModel } from "@follow/models/types"
import type { FeedModel, FeedOrListRespModel, InboxModel, ListModel } from "@follow/models/types"
import { useMutation } from "@tanstack/react-query"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
@ -14,8 +14,14 @@ import { listActions, useListStore } from "../list"
import { getPreferredTitle, useFeedStore } from "./store"
import type { FeedQueryParams } from "./types"
export const useFeedById = (feedId: Nullable<string>): FeedOrListRespModel | null =>
useFeedStore((state) => (feedId ? state.feeds[feedId] : null))
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 const useFeedByIdOrUrl = (feed: FeedQueryParams) =>
useFeedStore((state) => {

View File

@ -1,7 +1,6 @@
import type {
CombinedEntryModel,
FeedModel,
FeedOrListModel,
FeedOrListRespModel,
UserModel,
} from "@follow/models/types"
@ -38,18 +37,14 @@ class FeedActions {
set((state) =>
produce(state, (state) => {
for (const feed of feeds) {
if (
feed.type === "feed" &&
feed.errorAt &&
new Date(feed.errorAt).getTime() > Date.now() - distanceTime
) {
if (feed.errorAt && new Date(feed.errorAt).getTime() > Date.now() - distanceTime) {
feed.errorAt = null
}
if (feed.id) {
if (feed.owner) {
userActions.upsert(feed.owner as UserModel)
}
if (feed.type === "feed" && feed.tipUsers) {
if (feed.tipUsers) {
userActions.upsert(feed.tipUsers)
}
@ -62,7 +57,7 @@ class FeedActions {
feed.tipUsers = [...targetFeed.tipUsers]
}
state.feeds[feed.id] = omit(feed, "feeds") as FeedOrListModel
state.feeds[feed.id] = omit(feed, "feeds") as FeedModel
} else {
// Store temp feed in memory
const nonce = feed["nonce"] || nanoid(8)
@ -137,8 +132,8 @@ export const getFeedById = (feedId: string): Nullable<FeedOrListRespModel> =>
useFeedStore.getState().feeds[feedId]
export const getPreferredTitle = (
feed?: FeedOrListRespModel | null,
entry?: CombinedEntryModel["entries"],
feed?: Pick<FeedOrListRespModel, "type" | "id" | "title"> | null,
entry?: Pick<CombinedEntryModel["entries"], "authorUrl"> | null,
) => {
if (!feed?.id) {
return feed?.title

View File

@ -1,8 +1,8 @@
import type { FeedOrListModel } from "@follow/models/types"
import type { FeedModel } from "@follow/models/types"
type FeedId = string
export interface FeedState {
feeds: Record<FeedId, FeedOrListModel>
feeds: Record<FeedId, FeedModel>
}
export type FeedQueryParams = { id?: string; url?: string }

View File

@ -2,5 +2,14 @@ import type { InboxModel } from "@follow/models/types"
import { useInboxStore } from "./store"
export const useInboxById = (inboxId: Nullable<string>): InboxModel | null =>
useInboxStore((state) => (inboxId ? state.inboxes[inboxId] : null))
export function useInboxById(inboxId: Nullable<string>): InboxModel | null
export function useInboxById<T>(
inboxId: Nullable<string>,
selector: (inbox: InboxModel | null) => T,
): T
export function useInboxById<T>(inboxId: Nullable<string>, selector?: (inbox: InboxModel) => T) {
return useInboxStore((state) =>
inboxId ? (selector ? selector(state.inboxes[inboxId]) : state.inboxes[inboxId]) : null,
)
}

View File

@ -23,16 +23,17 @@ class ListActionStatic {
for (const list of lists) {
state.lists[list.id] = list
if (list.feeds)
for (const feed of list.feeds) {
feeds.push(feed)
}
if (!list.feeds) continue
for (const feed of list.feeds) {
feeds.push(feed)
}
}
feedActions.upsertMany(feeds)
return state
})
feedActions.upsertMany(feeds)
runTransactionInScope(() => ListService.upsertMany(lists))
}

View File

@ -184,6 +184,7 @@ class SubscriptionActions {
updateFeedBoostStatus(subscription.feedId, subscription.boost.boosters.length > 0)
}
}
this.updateCategoryOpenState(transformedData.filter((s) => s.category || s.defaultCategory))
feedActions.upsertMany(feeds)
listActions.upsertMany(lists)

View File

@ -46,10 +46,12 @@ export type EntriesResponse = Array<
| Exclude<Awaited<ReturnType<typeof _apiClient.entries.inbox.$post>>["data"], undefined>
>[number]
export type CombinedEntryModel = EntriesResponse[number] & {
export type CombinedEntryModel = Omit<EntriesResponse[number], "feeds"> & {
entries: {
content?: string | null
}
inboxes?: InboxModel
feeds?: EntriesResponse[number]["feeds"]
}
export type EntryModel = CombinedEntryModel["entries"]
export type EntryModelSimple = Exclude<