diff --git a/icons/mgc/rss_cute_fi.svg b/icons/mgc/rss_cute_fi.svg new file mode 100644 index 000000000..770ba82c0 --- /dev/null +++ b/icons/mgc/rss_cute_fi.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/mgc/search_2_cute_re.svg b/icons/mgc/search_2_cute_re.svg new file mode 100644 index 000000000..7c5e90731 --- /dev/null +++ b/icons/mgc/search_2_cute_re.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/renderer/src/atoms/app.ts b/src/renderer/src/atoms/app.ts index 57bab10d0..97894dca9 100644 --- a/src/renderer/src/atoms/app.ts +++ b/src/renderer/src/atoms/app.ts @@ -1,6 +1,10 @@ import { createAtomHooks } from "@renderer/lib/jotai" import { atom } from "jotai" -export const [, ,useAppIsReady, , , setAppIsReady] = createAtomHooks( +export const [, , useAppIsReady, , , setAppIsReady] = createAtomHooks( + atom(false), +) + +export const [, , useAppSearchOpen, , , setAppSearchOpen] = createAtomHooks( atom(false), ) diff --git a/src/renderer/src/components/common/LoadMoreIndicator.tsx b/src/renderer/src/components/common/LoadMoreIndicator.tsx new file mode 100644 index 000000000..18102bfb6 --- /dev/null +++ b/src/renderer/src/components/common/LoadMoreIndicator.tsx @@ -0,0 +1,19 @@ +import { useInView } from "react-intersection-observer" + +import { LoadingCircle } from "../ui/loading" + +export const LoadMoreIndicator: Component<{ + onLoading: () => void +}> = ({ onLoading, children, className }) => { + const { ref } = useInView({ + rootMargin: "1px", + onChange(inView) { + if (inView) onLoading() + }, + }) + return ( +
+ {children ?? } +
+ ) +} diff --git a/src/renderer/src/components/ui/placeholder.tsx b/src/renderer/src/components/ui/placeholder.tsx index 982714f7d..5385ac65d 100644 --- a/src/renderer/src/components/ui/placeholder.tsx +++ b/src/renderer/src/components/ui/placeholder.tsx @@ -1,3 +1,3 @@ export const ReactVirtuosoItemPlaceholder = () => // NOTE: prevent 0 height element, react virtuoso will not stop render any more -
+
diff --git a/src/renderer/src/components/ui/scroll-area/ScrollArea.tsx b/src/renderer/src/components/ui/scroll-area/ScrollArea.tsx index 3abe8418c..3a7c12513 100644 --- a/src/renderer/src/components/ui/scroll-area/ScrollArea.tsx +++ b/src/renderer/src/components/ui/scroll-area/ScrollArea.tsx @@ -96,19 +96,18 @@ export const Root = React.forwardRef< )) Root.displayName = "ScrollArea.Root" -export const ScrollArea: React.FC< +export const ScrollArea = React.forwardRef< + HTMLDivElement, React.PropsWithChildren & { rootClassName?: string viewportClassName?: string scrollbarClassName?: string } -> = ({ children, rootClassName, viewportClassName, scrollbarClassName }) => ( +>(({ children, rootClassName, viewportClassName, scrollbarClassName }, ref) => ( - + {children} - + -) +)) diff --git a/src/renderer/src/components/ui/select.tsx b/src/renderer/src/components/ui/select.tsx index 45d54088f..cb4e89e9f 100644 --- a/src/renderer/src/components/ui/select.tsx +++ b/src/renderer/src/components/ui/select.tsx @@ -27,7 +27,7 @@ const SelectTrigger = React.forwardRef< > {children} - + )) @@ -119,7 +119,7 @@ const SelectItem = React.forwardRef< { - if (params.isCollection) { - return true - } - if (!entry) return false - return entry.read - }, [entry?.read]) +export function useAsRead }>(entry?: T) { + return useRouteParamsSelector( + (params) => { + if (params.isCollection) { + return true + } + if (!entry) return false + return entry.read + }, + [entry?.read], + ) } diff --git a/src/renderer/src/hooks/biz/useEntryActions.tsx b/src/renderer/src/hooks/biz/useEntryActions.tsx index 6b51a05d0..6cc203261 100644 --- a/src/renderer/src/hooks/biz/useEntryActions.tsx +++ b/src/renderer/src/hooks/biz/useEntryActions.tsx @@ -4,7 +4,9 @@ import { nextFrame } from "@renderer/lib/dom" import { shortcuts } from "@renderer/lib/shortcuts" import type { CombinedEntryModel } from "@renderer/models" import { useTipModal } from "@renderer/modules/wallet/hooks" +import type { FlatEntryModel } from "@renderer/store/entry" import { entryActions } from "@renderer/store/entry" +import { useFeedById } from "@renderer/store/feed" import { useMutation, useQuery } from "@tanstack/react-query" import type { FetchError } from "ofetch" import { ofetch } from "ofetch" @@ -91,7 +93,7 @@ export const useEntryActions = ({ entry, }: { view?: number - entry?: CombinedEntryModel | null + entry?: FlatEntryModel | null }) => { const checkEagle = useQuery({ queryKey: ["check-eagle"], @@ -108,18 +110,29 @@ export const useEntryActions = ({ refetchOnWindowFocus: false, }) + const feed = useFeedById(entry?.feedId) + + const populatedEntry = useMemo(() => { + if (!entry) return null + if (!feed) return null + return { + ...entry, + feeds: feed!, + } as CombinedEntryModel + }, [entry, feed]) + const openTipModal = useTipModal({ - userId: entry?.feeds.ownerUserId ?? undefined, - feedId: entry?.feeds.id ?? undefined, + userId: populatedEntry?.feeds.ownerUserId ?? undefined, + feedId: populatedEntry?.feeds.id ?? undefined, }) - const collect = useCollect(entry) - const uncollect = useUnCollect(entry) + const collect = useCollect(populatedEntry) + const uncollect = useUnCollect(populatedEntry) const read = useRead() const unread = useUnread() const items = useMemo(() => { - if (!entry || view === undefined) return [] + if (!populatedEntry || view === undefined) return [] const items = [ [ { @@ -136,7 +149,7 @@ export const useEntryActions = ({ shortcut: shortcuts.entry.toggleStarred.key, name: `Star`, className: "i-mgc-star-cute-re", - disabled: !!entry.collections, + disabled: !!populatedEntry.collections, onClick: () => { collect.mutate() }, @@ -146,7 +159,7 @@ export const useEntryActions = ({ name: `Unstar`, shortcut: shortcuts.entry.toggleStarred.key, className: "i-mgc-star-cute-fi text-orange-500", - disabled: !entry.collections, + disabled: !populatedEntry.collections, onClick: () => { uncollect.mutate() }, @@ -154,11 +167,11 @@ export const useEntryActions = ({ { name: "Copy Link", className: "i-mgc-link-cute-re", - disabled: !entry.entries.url, + disabled: !populatedEntry.entries.url, shortcut: shortcuts.entry.copyLink.key, onClick: () => { - if (!entry.entries.url) return - navigator.clipboard.writeText(entry.entries.url) + if (!populatedEntry.entries.url) return + navigator.clipboard.writeText(populatedEntry.entries.url) toast("Link copied to clipboard.", { duration: 1000, }) @@ -169,10 +182,10 @@ export const useEntryActions = ({ name: `Open in Browser`, shortcut: shortcuts.entry.openInBrowser.key, className: "i-mgc-world-2-cute-re", - disabled: !entry.entries.url, + disabled: !populatedEntry.entries.url, onClick: () => { - if (!entry.entries.url) return - window.open(entry.entries.url, "_blank") + if (!populatedEntry.entries.url) return + window.open(populatedEntry.entries.url, "_blank") }, }, { @@ -180,12 +193,15 @@ export const useEntryActions = ({ icon: "/eagle.svg", disabled: (checkEagle.isLoading ? true : !checkEagle.data) || - !entry.entries.images?.length, + !populatedEntry.entries.images?.length, onClick: async () => { - if (!entry.entries.url || !entry.entries.images?.length) return + if ( + !populatedEntry.entries.url || + !populatedEntry.entries.images?.length + ) { return } const response = await tipcClient?.saveToEagle({ - url: entry.entries.url, - images: entry.entries.images, + url: populatedEntry.entries.url, + images: populatedEntry.entries.images, }) if (response?.status === "success") { toast("Saved to Eagle.", { @@ -202,8 +218,8 @@ export const useEntryActions = ({ name: "Share", className: "i-mgc-share-forward-cute-re", onClick: () => { - if (!entry.entries.url) return - tipcClient?.showShareMenu(entry.entries.url) + if (!populatedEntry.entries.url) return + tipcClient?.showShareMenu(populatedEntry.entries.url) }, }, { @@ -211,9 +227,9 @@ export const useEntryActions = ({ name: `Mark as Read`, shortcut: shortcuts.entry.toggleRead.key, className: "i-mgc-round-cute-fi", - disabled: !!entry.read || entry.collections, + disabled: !!populatedEntry.read || populatedEntry.collections, onClick: () => { - read.mutate(entry) + read.mutate(populatedEntry) }, }, { @@ -221,9 +237,9 @@ export const useEntryActions = ({ name: `Mark as Unread`, shortcut: shortcuts.entry.toggleRead.key, className: "i-mgc-round-cute-re", - disabled: !entry.read || entry.collections, + disabled: !populatedEntry.read || populatedEntry.collections, onClick: () => { - unread.mutate(entry) + unread.mutate(populatedEntry) }, }, ], @@ -234,7 +250,7 @@ export const useEntryActions = ({ checkEagle.data, checkEagle.isLoading, collect, - entry, + populatedEntry, read, openTipModal, uncollect, diff --git a/src/renderer/src/initialize/hydrate.ts b/src/renderer/src/initialize/hydrate.ts index adb964a90..99e95e284 100644 --- a/src/renderer/src/initialize/hydrate.ts +++ b/src/renderer/src/initialize/hydrate.ts @@ -2,7 +2,6 @@ import { initializeDefaultGeneralSettings } from "@renderer/atoms/settings/gener import { initializeDefaultUISettings } from "@renderer/atoms/settings/ui" import { appLog } from "@renderer/lib/log" import { sleep } from "@renderer/lib/utils" -import type { CombinedEntryModel, FeedModel } from "@renderer/models" import { EntryRelatedKey, EntryRelatedService, @@ -11,6 +10,7 @@ import { FeedUnreadService, SubscriptionService, } from "@renderer/services" +import type { FlatEntryModel } from "@renderer/store/entry" import { entryActions, useEntryStore } from "../store/entry/store" import { feedActions, useFeedStore } from "../store/feed" @@ -38,13 +38,13 @@ export const hydrateDatabaseToStore = async () => { async function hydrate() { const now = Date.now() - const [feeds] = await Promise.all([ + await Promise.all([ hydrateFeed(), hydrateSubscription(), hydrateFeedUnread(), + hydrateEntry(), ]) - await hydrateEntry(feeds) _isHydrated = true const costTime = Date.now() - now appLog("Hydrate data done,", `${costTime}ms`) @@ -72,7 +72,7 @@ async function hydrateFeedUnread() { return feedUnreadActions.hydrate(unread) } -async function hydrateEntry(feedMap: Record) { +async function hydrateEntry() { const [entries, entryRelated, feedEntries, collections] = await Promise.all([ EntryService.findAll(), @@ -81,30 +81,24 @@ async function hydrateEntry(feedMap: Record) { EntryRelatedService.findAll(EntryRelatedKey.COLLECTION), ]) - const storeValue = [] as CombinedEntryModel[] + const storeValue = [] as FlatEntryModel[] for (const entry of entries) { const entryRelatedFeedId = feedEntries[entry.id] if (!entryRelatedFeedId) { logHydrateError(`Entry ${entry.id} has no related feed id`) continue } - const feed = feedMap[entryRelatedFeedId] - - if (!feed) { - logHydrateError(`Entry related feed ${entryRelatedFeedId} is missing`) - continue - } storeValue.push({ entries: entry, - // @ts-expect-error - // FIXME server provided feed type is not match, but it's ok - feeds: feed, + feedId: entryRelatedFeedId, read: entryRelated[entry.id] || false, - collections: collections[entry.id], + collections: collections[entry.id] as { + createdAt: string + }, }) } - entryActions.upsertMany(storeValue) + entryActions.hydrate(storeValue) useEntryStore.setState({ starIds: new Set(Object.keys(collections)), }) diff --git a/src/renderer/src/lib/constants.tsx b/src/renderer/src/lib/constants.tsx index 669039af6..ac6bc65bd 100644 --- a/src/renderer/src/lib/constants.tsx +++ b/src/renderer/src/lib/constants.tsx @@ -3,10 +3,8 @@ import * as semver from "semver" import { getStorageNS } from "./ns" export const levels = { - view: "view", folder: "folder", feed: "feed", - entry: "entry", } export const views = [ @@ -86,4 +84,6 @@ export const ROUTE_FEED_PENDING = "all" export const ROUTE_ENTRY_PENDING = "pending" export const ROUTE_FEED_IN_FOLDER = "folder-" -export const channel = import.meta.env.DEV ? "development" : ((semver.prerelease(APP_VERSION)?.[0] as string) || "stable") +export const channel = import.meta.env.DEV ? + "development" : + (semver.prerelease(APP_VERSION)?.[0] as string) || "stable" diff --git a/src/renderer/src/lib/yielder.ts b/src/renderer/src/lib/yielder.ts new file mode 100644 index 000000000..5ec0296a0 --- /dev/null +++ b/src/renderer/src/lib/yielder.ts @@ -0,0 +1,16 @@ +export class Yielder { + private startTime: number + + constructor() { + this.startTime = performance.now() + } + + shouldYield(): boolean { + return performance.now() - this.startTime > 16 + } + + async yield() { + this.startTime = performance.now() + return new Promise((resolve) => setTimeout(resolve, 0)) + } +} diff --git a/src/renderer/src/modules/entry-column/grid-item-template.tsx b/src/renderer/src/modules/entry-column/grid-item-template.tsx index 7925c60ca..e741b08fe 100644 --- a/src/renderer/src/modules/entry-column/grid-item-template.tsx +++ b/src/renderer/src/modules/entry-column/grid-item-template.tsx @@ -5,6 +5,7 @@ import dayjs from "@renderer/lib/dayjs" import { cn } from "@renderer/lib/utils" import { EntryTranslation } from "@renderer/modules/entry-column/translation" import { useEntry } from "@renderer/store/entry/hooks" +import { useFeedById } from "@renderer/store/feed" import { StarIcon } from "./star-icon" import type { UniversalItemProps } from "./types" @@ -18,6 +19,7 @@ export function GridItem({ children?: React.ReactNode }) { const entry = useEntry(entryId) || entryPreview + const feeds = useFeedById(entry?.feedId) const asRead = useAsRead(entry) @@ -40,11 +42,11 @@ export function GridItem({
- {entry.feeds.title} + {feeds?.title} · {dayjs diff --git a/src/renderer/src/modules/entry-column/hooks.ts b/src/renderer/src/modules/entry-column/hooks.ts index 1125f4aac..693d1129d 100644 --- a/src/renderer/src/modules/entry-column/hooks.ts +++ b/src/renderer/src/modules/entry-column/hooks.ts @@ -146,7 +146,7 @@ function batchMarkRead(ids: string[]) { if (!entry) continue const isRead = entry.read if (!isRead) { - batchLikeIds.push([entry.feeds.id, id]) + batchLikeIds.push([entry.feedId, id]) } } diff --git a/src/renderer/src/modules/entry-column/index.tsx b/src/renderer/src/modules/entry-column/index.tsx index 4a0466a8a..f1c4bfbb2 100644 --- a/src/renderer/src/modules/entry-column/index.tsx +++ b/src/renderer/src/modules/entry-column/index.tsx @@ -76,10 +76,10 @@ export function EntryColumn() { if (isCollection || isPendingEntry) return - const feedId = activeEntry?.feeds.id + const feedId = activeEntry?.feedId if (!feedId) return batchMarkUnread([feedId, activeEntryId]) - }, [activeEntry?.feeds.id, activeEntryId, isCollection, isPendingEntry]) + }, [activeEntry?.feedId, activeEntryId, isCollection, isPendingEntry]) const isInteracted = useRef(false) diff --git a/src/renderer/src/modules/entry-column/item.tsx b/src/renderer/src/modules/entry-column/item.tsx index 82aa147e3..68b8c054c 100644 --- a/src/renderer/src/modules/entry-column/item.tsx +++ b/src/renderer/src/modules/entry-column/item.tsx @@ -8,8 +8,8 @@ import { views } from "@renderer/lib/constants" import { FeedViewType } from "@renderer/lib/enum" import { showNativeMenu } from "@renderer/lib/native-menu" import { cn } from "@renderer/lib/utils" -import type { CombinedEntryModel } from "@renderer/models" import { Queries } from "@renderer/queries" +import type { FlatEntryModel } from "@renderer/store/entry" import { useEntry } from "@renderer/store/entry/hooks" import type { FC } from "react" import { memo, useCallback } from "react" @@ -33,7 +33,7 @@ function EntryItemImpl({ entry, view, }: { - entry: CombinedEntryModel + entry: FlatEntryModel view?: number }) { const { items } = useEntryActions({ @@ -69,7 +69,7 @@ function EntryItemImpl({ if (!hoverMarkUnread) return if (asRead) return - batchMarkUnread([entry.feeds.id, entry.entries.id]) + batchMarkUnread([entry.feedId, entry.entries.id]) }, 233, { diff --git a/src/renderer/src/modules/entry-column/list-item-template.tsx b/src/renderer/src/modules/entry-column/list-item-template.tsx index c9fd5c298..adbe3298e 100644 --- a/src/renderer/src/modules/entry-column/list-item-template.tsx +++ b/src/renderer/src/modules/entry-column/list-item-template.tsx @@ -9,6 +9,7 @@ import dayjs from "@renderer/lib/dayjs" import { cn } from "@renderer/lib/utils" import { EntryTranslation } from "@renderer/modules/entry-column/translation" import { useEntry } from "@renderer/store/entry/hooks" +import { useFeedById } from "@renderer/store/feed" import { ReactVirtuosoItemPlaceholder } from "../../components/ui/placeholder" import { StarIcon } from "./star-icon" @@ -31,8 +32,11 @@ export function ListItem({ const inInCollection = useRouteParamsSelector( (s) => s.feedId === FEED_COLLECTION_LIST, ) + + const feed = useFeedById(entry?.feedId) + // NOTE: prevent 0 height element, react virtuoso will not stop render any more - if (!entry) return + if (!entry || !feed) return return (
- +
- {entry.feeds.title} + {feed.title} · {dayjs diff --git a/src/renderer/src/modules/entry-column/social-media-item.tsx b/src/renderer/src/modules/entry-column/social-media-item.tsx index 3c5bdb539..2e60f4772 100644 --- a/src/renderer/src/modules/entry-column/social-media-item.tsx +++ b/src/renderer/src/modules/entry-column/social-media-item.tsx @@ -4,6 +4,7 @@ import { useAsRead } from "@renderer/hooks/biz/useAsRead" import dayjs from "@renderer/lib/dayjs" import { cn } from "@renderer/lib/utils" import { useEntry } from "@renderer/store/entry/hooks" +import { useFeedById } from "@renderer/store/feed" import { ReactVirtuosoItemPlaceholder } from "../../components/ui/placeholder" import { StarIcon } from "./star-icon" @@ -14,12 +15,13 @@ export function SocialMediaItem({ entryId, entryPreview, translation }: Universa const entry = useEntry(entryId) || entryPreview const asRead = useAsRead(entry) + const feed = useFeedById(entry?.feedId) // NOTE: prevent 0 height element, react virtuoso will not stop render any more - if (!entry) return + if (!entry || !feed) return return (
- +
diff --git a/src/renderer/src/modules/entry-column/types.ts b/src/renderer/src/modules/entry-column/types.ts index 1a21128e5..efa1e0ec5 100644 --- a/src/renderer/src/modules/entry-column/types.ts +++ b/src/renderer/src/modules/entry-column/types.ts @@ -4,6 +4,7 @@ export type UniversalItemProps = { entryId: string entryPreview?: CombinedEntryModel & { feeds: FeedModel + feedId: string } translation?: { title?: string diff --git a/src/renderer/src/modules/entry-content/index.tsx b/src/renderer/src/modules/entry-content/index.tsx index a899e0bc3..fd30e2a69 100644 --- a/src/renderer/src/modules/entry-content/index.tsx +++ b/src/renderer/src/modules/entry-content/index.tsx @@ -15,7 +15,7 @@ import { } from "@renderer/providers/wrapped-element-provider" import { Queries } from "@renderer/queries" import { useEntry } from "@renderer/store/entry" -import { useFeedHeaderTitle } from "@renderer/store/feed" +import { useFeedById, useFeedHeaderTitle } from "@renderer/store/feed" import { useEffect, useState } from "react" import { LoadingCircle } from "../../components/ui/loading" @@ -55,6 +55,7 @@ function EntryContentRender({ entryId }: { entryId: string }) { }) const entry = useEntry(entryId) + const feed = useFeedById(entry?.feedId) useTitle(entry?.entries.title) const [content, setContent] = useState() const readerRenderInlineStyle = useUISettingKey("readerRenderInlineStyle") @@ -142,7 +143,7 @@ function EntryContentRender({ entryId }: { entryId: string }) { />
- {entry.feeds?.title} + {feed?.title}
{entry.entries.publishedAt && @@ -265,9 +266,11 @@ const TitleMetaHandler: Component<{ const isAtTop = useIsSoFWrappedElement() const { entries: { title: entryTitle }, - feeds: { title: feedTitle }, + feedId, } = useEntry(entryId)! + const { title: feedTitle } = useFeedById(feedId)! + useEffect(() => { if (!isAtTop && entryTitle && feedTitle) { setEntryTitleMeta({ title: entryTitle, description: feedTitle }) diff --git a/src/renderer/src/modules/feed-column/index.tsx b/src/renderer/src/modules/feed-column/index.tsx index 2154a7054..1eb5b28d6 100644 --- a/src/renderer/src/modules/feed-column/index.tsx +++ b/src/renderer/src/modules/feed-column/index.tsx @@ -1,4 +1,6 @@ +import { setAppSearchOpen } from "@renderer/atoms/app" import { getReadonlyRoute } from "@renderer/atoms/route" +import { useGeneralSettingKey } from "@renderer/atoms/settings/general" import { useSidebarActiveView } from "@renderer/atoms/sidebar" import { Logo } from "@renderer/components/icons/logo" import { ActionButton } from "@renderer/components/ui/button" @@ -6,7 +8,7 @@ import { ProfileButton } from "@renderer/components/user-button" import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry" import { useReduceMotion } from "@renderer/hooks/biz/useReduceMotion" import { getRouteParams } from "@renderer/hooks/biz/useRouteParams" -import { levels, views } from "@renderer/lib/constants" +import { views } from "@renderer/lib/constants" import { stopPropagation } from "@renderer/lib/dom" import { Routes } from "@renderer/lib/enum" import { shortcuts } from "@renderer/lib/shortcuts" @@ -17,13 +19,12 @@ import { useWheel } from "@use-gesture/react" import type { MotionValue } from "framer-motion" import { m, useSpring } from "framer-motion" import { Lethargy } from "lethargy" +import type { PropsWithChildren } from "react" import { useCallback, useLayoutEffect, useRef } from "react" import { isHotkeyPressed, useHotkeys } from "react-hotkeys-hook" import { Link } from "react-router-dom" import { Vibrancy } from "../../components/ui/background" -import { NetworkStatusIndicator } from "../app/NetworkStatusIndicator" -import { AutoUpdater } from "./auto-updater" import { FeedList } from "./list" const lethargy = new Lethargy() @@ -37,7 +38,6 @@ const useBackHome = (active: number) => { feedId: null, entryId: null, view: overvideActive ?? active, - level: levels.view, }) }, [active, navigate], @@ -60,7 +60,7 @@ const useUnreadByView = () => { return totalUnread } -export function FeedColumn() { +export function FeedColumn({ children }: PropsWithChildren) { const carouselRef = useRef(null) const [active, setActive_] = useSidebarActiveView() @@ -164,9 +164,11 @@ export function FeedColumn() {
)}
+ + @@ -217,14 +219,8 @@ export function FeedColumn() { ))}
- {APP_VERSION?.[0] === "0" && ( -
- Early Access -
- )} - - + {children} ) } @@ -258,3 +254,17 @@ const SwipeWrapper: Component<{ ) } + +const SearchActionButton = () => { + const canSearch = useGeneralSettingKey("dataPersist") + if (!canSearch) return null + return ( + setAppSearchOpen(true)} + > + + + ) +} diff --git a/src/renderer/src/modules/feed-column/list.tsx b/src/renderer/src/modules/feed-column/list.tsx index d2336c3b4..fe1e13771 100644 --- a/src/renderer/src/modules/feed-column/list.tsx +++ b/src/renderer/src/modules/feed-column/list.tsx @@ -103,7 +103,6 @@ export function FeedList({ navigate({ entryId: null, feedId: null, - level: levels.view, view, }) } diff --git a/src/renderer/src/modules/search/cmdk.module.css b/src/renderer/src/modules/search/cmdk.module.css new file mode 100644 index 000000000..378f82f9c --- /dev/null +++ b/src/renderer/src/modules/search/cmdk.module.css @@ -0,0 +1,17 @@ +.status-bar { + @apply scale-y-75 z-10 relative h-px w-full shrink-0 transform; + + &.loading::before { + @apply scale-y-75 z-10 h-px absolute bottom-0 w-full left-0 top-0 transform; + @apply bg-repeat; + + content: ""; + background: linear-gradient(90deg, transparent, #bbb, transparent); + animation: move 2s steps(60) infinite; + } +} + +.content-visually { + content-visibility: auto; + contain-intrinsic-size: auto 38px; +} diff --git a/src/renderer/src/modules/search/cmdk.tsx b/src/renderer/src/modules/search/cmdk.tsx new file mode 100644 index 000000000..c20654ad7 --- /dev/null +++ b/src/renderer/src/modules/search/cmdk.tsx @@ -0,0 +1,377 @@ +import { setAppSearchOpen, useAppSearchOpen } from "@renderer/atoms/app" +import { LoadMoreIndicator } from "@renderer/components/common/LoadMoreIndicator" +import { EmptyIcon } from "@renderer/components/icons/empty" +import { Logo } from "@renderer/components/icons/logo" +import { SiteIcon } from "@renderer/components/site-icon" +import { ScrollArea } from "@renderer/components/ui/scroll-area" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@renderer/components/ui/select" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@renderer/components/ui/tooltip" +import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry" +import { levels, ROUTE_ENTRY_PENDING } from "@renderer/lib/constants" +import { cn, pluralize } from "@renderer/lib/utils" +import { getFeedById } from "@renderer/store/feed" +import { searchActions, useSearchStore } from "@renderer/store/search" +import { SearchType } from "@renderer/store/search/constants" +import type { SearchInstance } from "@renderer/store/search/types" +import { useFeedUnreadStore } from "@renderer/store/unread" +import clsx from "clsx" +import { Command } from "cmdk" +import type { FC } from "react" +import * as React from "react" +import { memo, useMemo } from "react" + +import styles from "./cmdk.module.css" + +const SearchCmdKContext = React.createContext | null>( + null, +) +export const SearchCmdK: React.FC = () => { + const open = useAppSearchOpen() + + const [searchInstance, setSearchInstance] = React.useState(() => + searchActions.createLocalDbSearch(), + ) + React.useEffect(() => { + if (!open) return + + window.posthog?.capture("search_open") + // Refresh data + setPage(0) + setSearchInstance(() => searchActions.createLocalDbSearch()) + }, [open]) + + const entries = useSearchStore((s) => s.entries) + const feeds = useSearchStore((s) => s.feeds) + + const inputRef = React.useRef(null) + const dialogRef = React.useRef(null) + const scrollViewRef = React.useRef(null) + + React.useEffect(() => { + const $input = inputRef.current + if (open && $input) { + $input.focus() + } + }, [open]) + const handleKeyDownToFocusInput: React.EventHandler = + React.useCallback((e) => { + const $input = inputRef.current + if (e.key === "Escape") { + setAppSearchOpen(false) + return + } + + if (e.key === "ArrowDown" || e.key === "ArrowUp") return + + if (!e.ctrlKey && !e.metaKey && !e.altKey) { + $input?.focus() + } + }, []) + const [isPending, startTransition] = React.useTransition() + const handleSearch = React.useCallback( + async (value: string) => { + const { search } = await searchInstance + setPage(0) + startTransition(() => { + search(value) + const $scrollView = scrollViewRef.current + if ($scrollView) { + $scrollView.scrollTop = 0 + } + }) + }, + [searchInstance], + ) + // Performance optimization + const [page, setPage] = React.useState(0) + const pageSize = 16 + const renderedEntries = useMemo( + () => entries.slice(0, (page + 1) * pageSize), + [entries, page], + ) + + const renderedFeeds = useMemo(() => { + const delta = entries.length - renderedEntries.length + if (delta > pageSize) return [] + + const entriesTotalPage = Math.ceil(entries.length / pageSize) + const right = + entriesTotalPage === page + 1 ? delta : pageSize * page - entries.length + return feeds.slice(0, right) + }, [entries.length, feeds, page, renderedEntries.length]) + const totalCount = entries.length + feeds.length + const renderedTotalCount = renderedEntries.length + renderedFeeds.length + const loadMore = React.useCallback(() => { + const totalPage = Math.ceil((entries.length + feeds.length) / pageSize) + setPage((p) => { + if (p + 1 < totalPage) return p + 1 + return p + }) + }, [entries.length, feeds.length]) + + const canLoadMore = totalCount > renderedTotalCount + return ( + + + +
+ + + + + + {renderedEntries.length > 0 && ( + + )} + className="flex w-full min-w-0 flex-col py-2" + > + {renderedEntries.map((entry, index) => { + const feed = getFeedById(entry.feedId) + return ( + + ) + })} + + )} + {renderedFeeds.length > 0 && ( + + )} + className="py-2" + > + {renderedFeeds.map((feed, index) => ( + + ))} + + )} + {canLoadMore && ( + + )} + + + + + + + ) +} + +type SearchListType = { + title: string + subtitle?: Nullable + feedId?: string + entryId?: string + icon?: Nullable + id: string +} + +const SearchItem = memo(function Item({ + index, + ...item +}: { + index: number +} & SearchListType) { + const navigateEntry = useNavigateEntry() + return ( + { + navigateEntry({ + feedId: item.feedId!, + entryId: item.entryId, + + level: levels.feed, + }) + }} + > +
+ {item.icon && ( + + )} + + {item.title} + + + {item.subtitle} + +
+
+ ) +}) + +const SearchGroupHeading: FC<{ icon: string, title: string }> = ({ + icon, + title, +}) => ( +
+ + {title} +
+) + +const SearchResultCount: FC<{ + count?: number +}> = ({ count }) => { + const hasKeyword = useSearchStore((s) => !!s.keyword) + if (!count) return null + return ( + hasKeyword && ( + + + + {count} + {" "} + {pluralize("result", count)} + {" "} + (Local mode) + + + + + This search run on local database, the result may not be up-to-date. + + + ) + ) +} +const SearchOptions: Component = memo(({ children }) => { + const searchType = useSearchStore((s) => s.searchType) + + const searchInstance = React.useContext(SearchCmdKContext) + + return ( +
+ Search Type + + + + {children} +
+ ) +}) + +const SearchPlaceholder = () => { + const hasKeyword = useSearchStore((s) => !!s.keyword) + return ( + + {hasKeyword ? ( +
+ + No results found. +
+ ) : ( + + )} +
+ ) +} diff --git a/src/renderer/src/modules/settings/tabs/general.tsx b/src/renderer/src/modules/settings/tabs/general.tsx index a50ff45bb..31bcfbeff 100644 --- a/src/renderer/src/modules/settings/tabs/general.tsx +++ b/src/renderer/src/modules/settings/tabs/general.tsx @@ -82,7 +82,7 @@ export const SettingGeneral = () => { key: "dataPersist", label: "Persist data to offline usage", description: - "Data will be stored locally on your device for offline usage and speed up the data loading of the first screen. If you disable this, all local data will be removed.", + "Collects data to local data for offline access and provides local search feature.", onChange: (value) => setGeneralSetting("dataPersist", value), }, { diff --git a/src/renderer/src/pages/(external)/(with-layout)/feed/[id]/index.tsx b/src/renderer/src/pages/(external)/(with-layout)/feed/[id]/index.tsx index d900a9b09..22fed9b4d 100644 --- a/src/renderer/src/pages/(external)/(with-layout)/feed/[id]/index.tsx +++ b/src/renderer/src/pages/(external)/(with-layout)/feed/[id]/index.tsx @@ -6,7 +6,6 @@ import { useTitle } from "@renderer/hooks/common" import { views } from "@renderer/lib/constants" import { FeedViewType } from "@renderer/lib/enum" import { cn, pluralize } from "@renderer/lib/utils" -import type { FeedModel } from "@renderer/models" import { ArticleItem } from "@renderer/modules/entry-column/article-item" import { NotificationItem } from "@renderer/modules/entry-column/notification-item" import { PictureItem } from "@renderer/modules/entry-column/picture-item" @@ -113,13 +112,14 @@ export function Component() { > ))} diff --git a/src/renderer/src/pages/(main)/index.tsx b/src/renderer/src/pages/(main)/index.tsx index fd15692ff..9e16412ad 100644 --- a/src/renderer/src/pages/(main)/index.tsx +++ b/src/renderer/src/pages/(main)/index.tsx @@ -1,4 +1,4 @@ -import { levels, ROUTE_ENTRY_PENDING, ROUTE_FEED_PENDING } from "@renderer/lib/constants" +import { ROUTE_ENTRY_PENDING, ROUTE_FEED_PENDING } from "@renderer/lib/constants" import { FeedViewType } from "@renderer/lib/enum" import { redirect } from "react-router-dom" @@ -8,4 +8,4 @@ export function Component() { export const loader = () => // navigate to the first feed - redirect(`/feeds/${ROUTE_FEED_PENDING}/${ROUTE_ENTRY_PENDING}?view=${FeedViewType.Articles}&level=${levels.view}`) + redirect(`/feeds/${ROUTE_FEED_PENDING}/${ROUTE_ENTRY_PENDING}?view=${FeedViewType.Articles}`) diff --git a/src/renderer/src/pages/(main)/layout.tsx b/src/renderer/src/pages/(main)/layout.tsx index f8a674d27..100aa772b 100644 --- a/src/renderer/src/pages/(main)/layout.tsx +++ b/src/renderer/src/pages/(main)/layout.tsx @@ -4,8 +4,11 @@ import { DeclarativeModal } from "@renderer/components/ui/modal/stacked/declarat import { NoopChildren } from "@renderer/components/ui/modal/stacked/utils" import { RootPortal } from "@renderer/components/ui/portal" import { preventDefault } from "@renderer/lib/dom" +import { NetworkStatusIndicator } from "@renderer/modules/app/NetworkStatusIndicator" import { LoginModalContent } from "@renderer/modules/auth/LoginModalContent" import { FeedColumn } from "@renderer/modules/feed-column" +import { AutoUpdater } from "@renderer/modules/feed-column/auto-updater" +import { SearchCmdK } from "@renderer/modules/search/cmdk" import { Outlet } from "react-router-dom" export function Component() { @@ -15,7 +18,16 @@ export function Component() { return (
- + + {APP_VERSION?.[0] === "0" && ( +
+ Early Access +
+ )} + + + +
{/* NOTE: tabIndex for main element can get by `document.activeElement` */}
+ + {isAuthFail && !user && ( } + +type IdToIdRecord = Record +type IdToBooleanRecord = Record +type IdToAnyObjectRecord = Record> class ServiceStatic { - async findAll( - type: EntryRelatedKey, - ): Promise> { + async findAll(type: EntryRelatedKey.FEED_ID): Promise + async findAll(type: EntryRelatedKey.READ): Promise + async findAll(type: EntryRelatedKey.COLLECTION): Promise + + async findAll(type: EntryRelatedKey): Promise> { const data = await entryRelatedModel.table.get(type) return data ? data.data : {} } @@ -24,7 +30,19 @@ class ServiceStatic { * @param data key is entryId, value is read status * @returns */ - async upsert(type: EntryRelatedKey, data: Record) { + async upsert( + type: EntryRelatedKey.READ, + data: IdToBooleanRecord + ): Promise + async upsert( + type: EntryRelatedKey.FEED_ID, + data: IdToIdRecord + ): Promise + async upsert( + type: EntryRelatedKey.COLLECTION, + data: IdToAnyObjectRecord + ): Promise + async upsert(type: any, data: Record) { const oldData = await this.findAll(type) return entryRelatedModel.table.put({ @@ -33,11 +51,8 @@ class ServiceStatic { }) } - async deleteItem( - type: EntryRelatedKey, - key: string, - ) { - const oldData = await this.findAll(type) + async deleteItem(type: EntryRelatedKey, key: string) { + const oldData = await this.findAll(type as any) delete oldData[key] return entryRelatedModel.table.put({ diff --git a/src/renderer/src/services/entry.ts b/src/renderer/src/services/entry.ts index 36d86d365..70cb6fe7d 100644 --- a/src/renderer/src/services/entry.ts +++ b/src/renderer/src/services/entry.ts @@ -1,13 +1,10 @@ import { entryModel } from "@renderer/database/models" import type { - CombinedEntryModel, EntryModel, - FeedModel, } from "@renderer/models/types" import { BaseService } from "./base" import { EntryRelatedKey, EntryRelatedService } from "./entry-related" -import { FeedService } from "./feed" type EntryCollection = { createdAt: string @@ -17,21 +14,6 @@ class EntryServiceStatic extends BaseService { super(entryModel.table) } - pour(data: CombinedEntryModel[]) { - const entries = [] as EntryModel[] - const feeds = [] as FeedModel[] - for (const entry of data) { - entries.push(entry.entries) - - feeds.push(entry.feeds) - } - - return Promise.all([ - this.upsertMany(entries), - FeedService.upsertMany(feeds), - ]) - } - bulkStoreReadStatus(record: Record) { return EntryRelatedService.upsert(EntryRelatedKey.READ, record) } diff --git a/src/renderer/src/store/entry/hooks.ts b/src/renderer/src/store/entry/hooks.ts index acaa1ea1b..d46063319 100644 --- a/src/renderer/src/store/entry/hooks.ts +++ b/src/renderer/src/store/entry/hooks.ts @@ -3,18 +3,15 @@ import { ROUTE_FEED_IN_FOLDER, } from "@renderer/lib/constants" import type { FeedViewType } from "@renderer/lib/enum" -import type { CombinedEntryModel } from "@renderer/models" import { useShallow } from "zustand/react/shallow" import { useFeedIdByView, useFolderFeedsByFeedId } from "../subscription" import { getEntryIsInView } from "../utils/biz" import { getFilteredFeedIds } from "./helper" import { useEntryStore } from "./store" -import type { EntryFilter } from "./types" +import type { EntryFilter, FlatEntryModel } from "./types" -export const useEntry = ( - entryId: Nullable, -): CombinedEntryModel | null => +export const useEntry = (entryId: Nullable): FlatEntryModel | null => useEntryStore( useShallow((state) => (entryId ? state.flatMapEntries[entryId] : null)), ) @@ -25,8 +22,7 @@ export const useEntryIdsByFeedId = (feedId: string, filter?: EntryFilter) => 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(",") @@ -73,9 +69,7 @@ export const useEntryIdsByFeedId = (feedId: string, filter?: EntryFilter) => export const useEntryIdsByView = (view: FeedViewType, filter?: EntryFilter) => { const feedIds = useFeedIdByView(view) - return useEntryStore( - useShallow(() => getFilteredFeedIds(feedIds, filter)), - ) + return useEntryStore(useShallow(() => getFilteredFeedIds(feedIds, filter))) } export const useEntryIdsByFolderName = ( diff --git a/src/renderer/src/store/entry/store.ts b/src/renderer/src/store/entry/store.ts index cb15294d4..55d8ab4b0 100644 --- a/src/renderer/src/store/entry/store.ts +++ b/src/renderer/src/store/entry/store.ts @@ -16,7 +16,7 @@ import { isHydrated } from "../../initialize/hydrate" import { feedActions } from "../feed" import { feedUnreadActions } from "../unread" import { createZustandStore } from "../utils/helper" -import type { EntryState } from "./types" +import type { EntryState, FlatEntryModel } from "./types" const createState = (): EntryState => ({ entries: {}, @@ -140,6 +140,7 @@ class EntryActions { const entry2Read = {} as Record const entryFeedMap = {} as Record const entryCollection = {} as Record + set((state) => produce(state, (draft) => { for (const item of data) { @@ -164,7 +165,10 @@ class EntryActions { draft.flatMapEntries[item.entries.id] = merge( draft.flatMapEntries[item.entries.id] || {}, - item, + { + feedId: item.feeds.id, + }, + omit(item, "feeds"), ) // Push feed @@ -203,6 +207,52 @@ class EntryActions { } } + hydrate(data: FlatEntryModel[]) { + const entryCollection = {} as Record + + set((state) => + produce(state, (draft) => { + for (const item of data) { + if (!draft.entries[item.feedId]) { + draft.entries[item.feedId] = [] + } + + if (!draft.internal_feedId2entryIdSet[item.feedId]) { + draft.internal_feedId2entryIdSet[item.feedId] = new Set() + } + + if ( + !draft.internal_feedId2entryIdSet[item.feedId].has(item.entries.id) + ) { + draft.entries[item.feedId].push(item.entries.id) + draft.internal_feedId2entryIdSet[item.feedId].add(item.entries.id) + } + + draft.flatMapEntries[item.entries.id] = merge( + draft.flatMapEntries[item.entries.id] || {}, + item, + ) + + // Push entryCollection + if (item.collections) { + entryCollection[item.entries.id] = item.collections + } + } + + return draft + }), + ) + + const newStarIds = new Set(get().starIds) + for (const entryId in entryCollection) { + newStarIds.add(entryId) + } + set((state) => ({ + ...state, + starIds: newStarIds, + })) + } + markRead(feedId: string, entryId: string, read: boolean) { feedUnreadActions.incrementByFeedId(feedId, read ? -1 : 1) this.patch(entryId, { diff --git a/src/renderer/src/store/entry/types.ts b/src/renderer/src/store/entry/types.ts index 5f5d7eef2..62b60984f 100644 --- a/src/renderer/src/store/entry/types.ts +++ b/src/renderer/src/store/entry/types.ts @@ -5,6 +5,7 @@ type FeedId = string type EntryId = string type EntriesIdTable = Record +export type FlatEntryModel = Omit & { feedId: FeedId } export interface EntryState { /** * A map of feedId to entryIds @@ -13,7 +14,7 @@ export interface EntryState { /** * A map of entryId to entry */ - flatMapEntries: Record + flatMapEntries: Record /** * A map of feedId to entryId set, to quickly check if an entryId is in the feed * The array is used to keep the order of the entries, and this set is used to quickly check if an entryId is in the feed diff --git a/src/renderer/src/store/search/constants.ts b/src/renderer/src/store/search/constants.ts new file mode 100644 index 000000000..386a60b24 --- /dev/null +++ b/src/renderer/src/store/search/constants.ts @@ -0,0 +1,12 @@ +const SearchTypeBase = { + Feed: 1, + Entry: 1 << 1, + Subscription: 1 << 2, +} + +export const SearchType = { + ...SearchTypeBase, + All: Object.values(SearchTypeBase).reduce((acc, cur) => acc | cur, 0), +} + +export type SearchType = typeof SearchType[keyof typeof SearchType] diff --git a/src/renderer/src/store/search/helper.ts b/src/renderer/src/store/search/helper.ts new file mode 100644 index 000000000..c2a99ec76 --- /dev/null +++ b/src/renderer/src/store/search/helper.ts @@ -0,0 +1,3 @@ +import type { SearchInstance } from "./types" + +export const defineSearchInstance = (instance: SearchInstance) => instance diff --git a/src/renderer/src/store/search/index.ts b/src/renderer/src/store/search/index.ts new file mode 100644 index 000000000..223557edc --- /dev/null +++ b/src/renderer/src/store/search/index.ts @@ -0,0 +1,118 @@ +import type { EntryModel } from "@renderer/models" +import { + EntryRelatedKey, + EntryRelatedService, + EntryService, + FeedService, + SubscriptionService, +} from "@renderer/services" +import type { IFuseOptions } from "fuse.js" +import Fuse from "fuse.js" + +import type { SubscriptionPlainModel } from "../subscription" +import { createZustandStore } from "../utils/helper" +import { SearchType } from "./constants" +import { defineSearchInstance } from "./helper" +import type { SearchResult, SearchState } from "./types" + +const createState = (): SearchState => ({ + feeds: [], + entries: [], + subscriptions: [], + keyword: "", + searchType: SearchType.All, +}) +export const useSearchStore = + createZustandStore("search")(createState) + +const { getState: get, setState: set } = useSearchStore + +class SearchActions { + reset() { + set(createState) + } + + private createFuse(data: T[], keys: (keyof T)[]) { + const options: IFuseOptions = { + keys: keys as any, + } + const index = Fuse.createIndex(options.keys!, data) + return new Fuse(data, options, index) + } + + async createLocalDbSearch() { + const [entries, feeds, subscriptions, entryRelated] = await Promise.all([ + EntryService.findAll(), + FeedService.findAll(), + SubscriptionService.findAll(), + EntryRelatedService.findAll(EntryRelatedKey.FEED_ID), + ]) + + const entriesFuse = this.createFuse(entries, [ + "title", + "content", + "description", + ]) + const feedsFuse = this.createFuse(feeds, ["title", "description"]) + const subscriptionsFuse = this.createFuse(subscriptions, [ + "title", + "category", + ]) + + return defineSearchInstance({ + search(keyword: string) { + const type = get().searchType + const entries = + type & SearchType.Entry ? entriesFuse.search(keyword) : [] + const feeds = type & SearchType.Feed ? feedsFuse.search(keyword) : [] + + const subscriptions = + type & SearchType.Subscription ? + subscriptionsFuse.search(keyword) : + [] + + const processedEntries = [] as SearchResult< + EntryModel, + { feedId: string } + >[] + for (const entry of entries) { + const feedId = entryRelated[entry.item.id] + if (feedId) { + processedEntries.push({ item: entry.item, feedId }) + } + } + + const processedSubscriptions = [] as SearchResult< + SubscriptionPlainModel, + { feedId: string } + >[] + for (const subscription of subscriptions) { + const { feedId } = subscription.item + if (feedId) { + processedSubscriptions.push({ item: subscription.item, feedId }) + } + } + + set({ + keyword, + entries: processedEntries, + feeds, + subscriptions: processedSubscriptions, + searchType: type, + }) + + return get() + }, + }) + } + + setSearchType(type: SearchType) { + set({ searchType: type }) + } + + getCurrentKeyword() { + return get().keyword + } +} + +export const searchActions = new SearchActions() diff --git a/src/renderer/src/store/search/types.ts b/src/renderer/src/store/search/types.ts new file mode 100644 index 000000000..93ff7b00f --- /dev/null +++ b/src/renderer/src/store/search/types.ts @@ -0,0 +1,22 @@ +import type { EntryModel, FeedModel } from "@renderer/models" + +import type { SubscriptionPlainModel } from "../subscription" +import type { SearchType } from "./constants" + +// @ts-expect-error +export interface SearchResult + extends A { + item: T +} + +export interface SearchState { + feeds: SearchResult[] + entries: SearchResult[] + subscriptions: SearchResult[] + + keyword: string + searchType: SearchType +} +export interface SearchInstance { + search: (keyword: string) => SearchState +} diff --git a/src/renderer/src/store/unread.ts b/src/renderer/src/store/unread/index.ts similarity index 97% rename from src/renderer/src/store/unread.ts rename to src/renderer/src/store/unread/index.ts index dbbc5f994..5f37530dd 100644 --- a/src/renderer/src/store/unread.ts +++ b/src/renderer/src/store/unread/index.ts @@ -2,7 +2,7 @@ import { apiClient } from "@renderer/lib/api-fetch" import type { FeedViewType } from "@renderer/lib/enum" import { FeedUnreadService } from "@renderer/services" -import { createZustandStore } from "./utils/helper" +import { createZustandStore } from "../utils/helper" interface UnreadState { data: Record diff --git a/src/renderer/src/store/utils/biz.ts b/src/renderer/src/store/utils/biz.ts index cbf929b06..3eb80e633 100644 --- a/src/renderer/src/store/utils/biz.ts +++ b/src/renderer/src/store/utils/biz.ts @@ -6,7 +6,7 @@ export const getEntryIsInView = (entryId: string) => { const state = useEntryStore.getState() const entry = state.flatMapEntries[entryId] if (!entry) return - const feedId = entry.feeds.id + const { feedId } = entry const feed = useFeedStore.getState().feeds[feedId] if (!feed?.id) return const subscription = useSubscriptionStore.getState().data[feed.id]