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]