Merge remote-tracking branch 'origin/feat/local-search'

This commit is contained in:
DIYgod 2024-07-15 23:56:34 +08:00
commit 6ccc61c2de
No known key found for this signature in database
40 changed files with 829 additions and 151 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path fill="#10161F" fill-rule="evenodd" d="M11.427 2.5h1.146c1.824 0 3.293 0 4.45.155 1.2.162 2.21.507 3.012 1.31.803.802 1.148 1.813 1.31 3.013.155 1.156.155 2.625.155 4.449v1.146c0 1.824 0 3.293-.155 4.45-.162 1.2-.507 2.21-1.31 3.012-.802.803-1.812 1.148-3.013 1.31-1.156.155-2.625.155-4.449.155h-1.146c-1.824 0-3.293 0-4.45-.155-1.2-.162-2.21-.507-3.013-1.31-.802-.802-1.147-1.812-1.309-3.013-.155-1.156-.155-2.625-.155-4.449v-1.146c0-1.824 0-3.293.155-4.45.162-1.2.507-2.21 1.31-3.013.802-.802 1.813-1.147 3.013-1.309C8.134 2.5 9.603 2.5 11.427 2.5M8.5 9c-.146 0-.29.005-.434.014a1 1 0 1 1-.132-1.995 8.5 8.5 0 0 1 9.047 9.047 1 1 0 1 1-1.995-.132A6.5 6.5 0 0 0 8.5 9M7 11.5a1 1 0 0 1 1-1 5.5 5.5 0 0 1 5.5 5.5 1 1 0 1 1-2 0A3.5 3.5 0 0 0 8 12.5a1 1 0 0 1-1-1m0 4a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 965 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path stroke="#10161F" stroke-linecap="round" stroke-width="2" d="M14.5 14.5 20 20m-4-10a6 6 0 1 1-12 0 6 6 0 0 1 12 0Z"/></svg>

After

Width:  |  Height:  |  Size: 259 B

View File

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

View File

@ -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 (
<div className={className} ref={ref}>
{children ?? <LoadingCircle size="small" />}
</div>
)
}

View File

@ -1,3 +1,3 @@
export const ReactVirtuosoItemPlaceholder = () =>
// NOTE: prevent 0 height element, react virtuoso will not stop render any more
<div className="h-px" />
<div className="h-[0.0000001px]" />

View File

@ -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) => (
<Root className={rootClassName}>
<Viewport onWheel={stopPropagation} className={viewportClassName}>
<Viewport ref={ref} onWheel={stopPropagation} className={viewportClassName}>
{children}
</Viewport>
<Scrollbar
className={scrollbarClassName}
/>
<Scrollbar className={scrollbarClassName} />
</Root>
)
))

View File

@ -27,7 +27,7 @@ const SelectTrigger = React.forwardRef<
>
{children}
<SelectPrimitive.Icon asChild>
<i className="i-mingcute-down-line size-4 opacity-50" />
<i className="i-mingcute-down-line ml-2 size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
@ -119,7 +119,7 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-theme-item-active focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"pointer-events-auto relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-theme-item-active focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
)}
{...props}

View File

@ -1,13 +1,14 @@
import type { CombinedEntryModel } from "@renderer/models"
import { useRouteParamsSelector } from "./useRouteParams"
export function useAsRead(entry?: CombinedEntryModel) {
return useRouteParamsSelector((params) => {
if (params.isCollection) {
return true
}
if (!entry) return false
return entry.read
}, [entry?.read])
export function useAsRead<T extends { read: Nullable<boolean> }>(entry?: T) {
return useRouteParamsSelector(
(params) => {
if (params.isCollection) {
return true
}
if (!entry) return false
return entry.read
},
[entry?.read],
)
}

View File

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

View File

@ -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<string, FeedModel>) {
async function hydrateEntry() {
const [entries, entryRelated, feedEntries, collections] = await Promise.all([
EntryService.findAll(),
@ -81,30 +81,24 @@ async function hydrateEntry(feedMap: Record<string, FeedModel>) {
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)),
})

View File

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

View File

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

View File

@ -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({
<div className="flex items-center gap-1 truncate text-[13px]">
<FeedIcon
className="mr-0.5 inline-block"
feed={entry.feeds}
feed={feeds!}
entry={entry.entries}
size={18}
/>
<span>{entry.feeds.title}</span>
<span>{feeds?.title}</span>
<span className="text-zinc-500">·</span>
<span className="text-zinc-500">
{dayjs

View File

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

View File

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

View File

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

View File

@ -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 <ReactVirtuosoItemPlaceholder />
if (!entry || !feed) return <ReactVirtuosoItemPlaceholder />
return (
<div
@ -42,7 +46,7 @@ export function ListItem({
"before:absolute before:-left-0.5 before:top-[18px] before:block before:size-2 before:rounded-full before:bg-theme-accent",
)}
>
<FeedIcon feed={entry.feeds} entry={entry.entries} />
<FeedIcon feed={feed} entry={entry.entries} />
<div className="-mt-0.5 line-clamp-4 flex-1 text-sm leading-tight">
<div
className={cn(
@ -51,7 +55,7 @@ export function ListItem({
entry.collections && "text-zinc-600 dark:text-zinc-500",
)}
>
<span className="truncate">{entry.feeds.title}</span>
<span className="truncate">{feed.title}</span>
<span>·</span>
<span className="shrink-0">
{dayjs

View File

@ -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 <ReactVirtuosoItemPlaceholder />
if (!entry || !feed) return <ReactVirtuosoItemPlaceholder />
return (
<div className={cn("relative flex w-full py-3 pl-3 pr-2", !asRead && "before:absolute before:-left-0.5 before:top-[22px] before:block before:size-2 before:rounded-full before:bg-theme-accent")}>
<FeedIcon feed={entry.feeds} entry={entry.entries} size={28} />
<FeedIcon feed={feed} entry={entry.entries} size={28} />
<div className="min-w-0 flex-1">
<div className={cn("-mt-0.5 flex-1 text-sm", entry.entries.description && "line-clamp-5")}>
<div className="space-x-1">

View File

@ -4,6 +4,7 @@ export type UniversalItemProps = {
entryId: string
entryPreview?: CombinedEntryModel & {
feeds: FeedModel
feedId: string
}
translation?: {
title?: string

View File

@ -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<JSX.Element>()
const readerRenderInlineStyle = useUISettingKey("readerRenderInlineStyle")
@ -142,7 +143,7 @@ function EntryContentRender({ entryId }: { entryId: string }) {
/>
</div>
<div className="mt-2 text-[13px] font-medium text-zinc-500">
{entry.feeds?.title}
{feed?.title}
</div>
<div className="text-[13px] text-zinc-500">
{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 })

View File

@ -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<HTMLDivElement>(null)
const [active, setActive_] = useSidebarActiveView()
@ -164,9 +164,11 @@ export function FeedColumn() {
</div>
)}
<div
className="relative flex items-center gap-2"
className="relative flex items-center gap-1"
onClick={stopPropagation}
>
<SearchActionButton />
<Link to="/discover" tabIndex={-1}>
<ActionButton shortcut="Meta+T" tooltip="Add">
<i className="i-mgc-add-cute-re size-5 text-theme-vibrancyFg" />
@ -217,14 +219,8 @@ export function FeedColumn() {
))}
</SwipeWrapper>
</div>
{APP_VERSION?.[0] === "0" && (
<div className="pointer-events-none absolute bottom-3 w-full text-center text-xs opacity-20">
Early Access
</div>
)}
<AutoUpdater />
<NetworkStatusIndicator />
{children}
</Vibrancy>
)
}
@ -258,3 +254,17 @@ const SwipeWrapper: Component<{
</m.div>
)
}
const SearchActionButton = () => {
const canSearch = useGeneralSettingKey("dataPersist")
if (!canSearch) return null
return (
<ActionButton
shortcut="Meta+K"
tooltip="Search"
onClick={() => setAppSearchOpen(true)}
>
<i className="i-mgc-search-2-cute-re size-5 text-theme-vibrancyFg" />
</ActionButton>
)
}

View File

@ -103,7 +103,6 @@ export function FeedList({
navigate({
entryId: null,
feedId: null,
level: levels.view,
view,
})
}

View File

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

View File

@ -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<Promise<SearchInstance> | 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<HTMLInputElement>(null)
const dialogRef = React.useRef<HTMLDivElement>(null)
const scrollViewRef = React.useRef<HTMLDivElement>(null)
React.useEffect(() => {
const $input = inputRef.current
if (open && $input) {
$input.focus()
}
}, [open])
const handleKeyDownToFocusInput: React.EventHandler<React.KeyboardEvent> =
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 (
<SearchCmdKContext.Provider value={searchInstance}>
<Command.Dialog
ref={dialogRef}
shouldFilter={false}
open={open}
onKeyDown={handleKeyDownToFocusInput}
onOpenChange={setAppSearchOpen}
className={cn(
"h-[600px] max-h-[80vh] w-[800px] max-w-[100vw] rounded-none md:h-screen md:max-h-[60vh] md:max-w-[80vw]",
"flex min-h-[50vh] flex-col bg-zinc-50/85 shadow-2xl backdrop-blur-md dark:bg-neutral-900/80 md:rounded-xl",
"border-0 border-zinc-200 dark:border-zinc-800 md:border",
"absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2",
)}
>
<Command.Input
className="w-full shrink-0 border-b border-zinc-200 bg-transparent p-4 px-5 text-lg leading-4 dark:border-neutral-700"
ref={inputRef}
placeholder={searchActions.getCurrentKeyword() || "Search..."}
onValueChange={handleSearch}
/>
<div
className={cn(styles["status-bar"], isPending && styles["loading"])}
/>
<ScrollArea.ScrollArea
ref={scrollViewRef}
viewportClassName="max-h-[50vh] px-5 [&>div]:!flex"
rootClassName="h-full"
>
<Command.List className="flex w-full min-w-0 flex-col">
<SearchPlaceholder />
{renderedEntries.length > 0 && (
<Command.Group
heading={(
<SearchGroupHeading
icon="i-mgc-paper-cute-fi size-4"
title="Entries"
/>
)}
className="flex w-full min-w-0 flex-col py-2"
>
{renderedEntries.map((entry, index) => {
const feed = getFeedById(entry.feedId)
return (
<SearchItem
key={entry.item.id}
title={entry.item.title!}
feedId={entry.feedId}
entryId={entry.item.id}
id={entry.item.id}
index={index}
icon={feed?.siteUrl}
subtitle={feed?.title}
/>
)
})}
</Command.Group>
)}
{renderedFeeds.length > 0 && (
<Command.Group
heading={(
<SearchGroupHeading
icon="i-mgc-rss-cute-fi size-4 text-theme-accent"
title="Feeds"
/>
)}
className="py-2"
>
{renderedFeeds.map((feed, index) => (
<SearchItem
key={feed.item.id}
title={feed.item.title!}
feedId={feed.item.id!}
entryId={ROUTE_ENTRY_PENDING}
id={feed.item.id!}
index={entries.length + index}
icon={feed.item.siteUrl}
subtitle={useFeedUnreadStore
.getState()
.data[feed.item.id!]?.toString()}
/>
))}
</Command.Group>
)}
{canLoadMore && (
<LoadMoreIndicator
className="center w-full"
onLoading={loadMore}
/>
)}
</Command.List>
</ScrollArea.ScrollArea>
<SearchOptions />
<SearchResultCount count={totalCount} />
</Command.Dialog>
</SearchCmdKContext.Provider>
)
}
type SearchListType = {
title: string
subtitle?: Nullable<string>
feedId?: string
entryId?: string
icon?: Nullable<string>
id: string
}
const SearchItem = memo(function Item({
index,
...item
}: {
index: number
} & SearchListType) {
const navigateEntry = useNavigateEntry()
return (
<Command.Item
className={clsx(
"relative flex w-full justify-between px-1 text-[0.9rem]",
"before:absolute before:inset-0 before:rounded-md before:content-auto",
"before:z-0 hover:before:bg-zinc-200/60 dark:hover:before:bg-zinc-800/80",
"data-[selected=true]:before:bg-zinc-200/60 data-[selected=true]:dark:before:bg-zinc-800/80",
"min-w-0 max-w-full",
styles["content-visually"],
)}
key={item.id}
onSelect={() => {
navigateEntry({
feedId: item.feedId!,
entryId: item.entryId,
level: levels.feed,
})
}}
>
<div className="relative z-10 flex w-full items-center justify-between px-1 py-2">
{item.icon && (
<SiteIcon className="mr-2 size-5 shrink-0" url={item.icon} />
)}
<span className="block min-w-0 flex-1 shrink-0 truncate">
{item.title}
</span>
<span className="block min-w-0 shrink-0 grow-0 text-xs font-medium text-zinc-800 opacity-60 dark:text-slate-200/80">
{item.subtitle}
</span>
</div>
</Command.Item>
)
})
const SearchGroupHeading: FC<{ icon: string, title: string }> = ({
icon,
title,
}) => (
<div className="mb-2 flex items-center gap-2">
<i className={icon} />
<span className="text-sm font-semibold">{title}</span>
</div>
)
const SearchResultCount: FC<{
count?: number
}> = ({ count }) => {
const hasKeyword = useSearchStore((s) => !!s.keyword)
if (!count) return null
return (
hasKeyword && (
<Tooltip>
<TooltipTrigger asChild>
<small className="center absolute bottom-3 right-3 shrink-0 gap-1 opacity-80">
{count}
{" "}
{pluralize("result", count)}
{" "}
(Local mode)
<i className="i-mingcute-question-line" />
</small>
</TooltipTrigger>
<TooltipContent>
This search run on local database, the result may not be up-to-date.
</TooltipContent>
</Tooltip>
)
)
}
const SearchOptions: Component = memo(({ children }) => {
const searchType = useSearchStore((s) => s.searchType)
const searchInstance = React.useContext(SearchCmdKContext)
return (
<div className="absolute bottom-2 left-4 flex items-center gap-2 text-sm text-theme-foreground/80">
<span className="shrink-0">Search Type</span>
<Select
onValueChange={async (value) => {
searchActions.setSearchType(+value as SearchType)
if (searchInstance) {
const { search } = await searchInstance
search(searchActions.getCurrentKeyword())
}
}}
value={`${searchType}`}
>
<SelectTrigger size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem
className="hover:bg-theme-item-hover"
value={`${SearchType.All}`}
disabled={searchType === SearchType.All}
>
All
</SelectItem>
<SelectItem
className="hover:bg-theme-item-hover"
value={`${SearchType.Entry}`}
disabled={searchType === SearchType.Entry}
>
Entries
</SelectItem>
<SelectItem
className="hover:bg-theme-item-hover"
value={`${SearchType.Feed}`}
disabled={searchType === SearchType.Feed}
>
Feeds
</SelectItem>
</SelectContent>
</Select>
{children}
</div>
)
})
const SearchPlaceholder = () => {
const hasKeyword = useSearchStore((s) => !!s.keyword)
return (
<Command.Empty className="center absolute inset-0">
{hasKeyword ? (
<div className="flex flex-col items-center justify-center gap-2 opacity-80">
<EmptyIcon />
No results found.
</div>
) : (
<Logo className="size-12 opacity-80 grayscale" />
)}
</Command.Empty>
)
}

View File

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

View File

@ -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() {
>
<ListItemHoverOverlay />
<Item
entryId=""
entryPreview={{
entries: entry,
// @ts-expect-error
feeds: feed.data.feed as FeedModel,
read: true,
}}
// entryId=""
// entryPreview={{
// entries: entry,
// // @ts-expect-error
// feeds: feed.data.feed as FeedModel,
// read: true,
// }}
entryId={entry.id}
/>
</a>
))}

View File

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

View File

@ -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 (
<div className="flex h-full" onContextMenu={preventDefault}>
<div className="w-64 shrink-0 border-r">
<FeedColumn />
<FeedColumn>
{APP_VERSION?.[0] === "0" && (
<div className="pointer-events-none absolute bottom-3 w-full text-center text-xs opacity-20">
Early Access
</div>
)}
<AutoUpdater />
<NetworkStatusIndicator />
</FeedColumn>
</div>
{/* NOTE: tabIndex for main element can get by `document.activeElement` */}
<main
@ -25,6 +37,8 @@ export function Component() {
>
<Outlet />
</main>
<SearchCmdK />
{isAuthFail && !user && (
<RootPortal>
<DeclarativeModal

View File

@ -1,7 +1,8 @@
import { apiClient } from "@renderer/lib/api-fetch"
import { views } from "@renderer/lib/constants"
import { defineQuery } from "@renderer/lib/defineQuery"
import type { CombinedEntryModel, SupportedLanguages } from "@renderer/models"
import type { SupportedLanguages } from "@renderer/models"
import type { FlatEntryModel } from "@renderer/store/entry"
import { franc } from "franc-min"
const LanguageMap: Record<
@ -31,7 +32,7 @@ export const ai = {
language,
extraFields,
}: {
entry: CombinedEntryModel
entry: FlatEntryModel
view?: number
language?: SupportedLanguages
extraFields?: string[]

View File

@ -11,10 +11,16 @@ type Model = {
id: EntryRelatedKey
data: Record<string, any>
}
type IdToIdRecord = Record<string, string>
type IdToBooleanRecord = Record<string, boolean>
type IdToAnyObjectRecord = Record<string, Record<string, any>>
class ServiceStatic {
async findAll(
type: EntryRelatedKey,
): Promise<Record<string, any>> {
async findAll(type: EntryRelatedKey.FEED_ID): Promise<IdToIdRecord>
async findAll(type: EntryRelatedKey.READ): Promise<IdToBooleanRecord>
async findAll(type: EntryRelatedKey.COLLECTION): Promise<IdToAnyObjectRecord>
async findAll(type: EntryRelatedKey): Promise<Record<string, any>> {
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<string, any>) {
async upsert(
type: EntryRelatedKey.READ,
data: IdToBooleanRecord
): Promise<void>
async upsert(
type: EntryRelatedKey.FEED_ID,
data: IdToIdRecord
): Promise<void>
async upsert(
type: EntryRelatedKey.COLLECTION,
data: IdToAnyObjectRecord
): Promise<void>
async upsert(type: any, data: Record<string, any>) {
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({

View File

@ -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<EntryModel> {
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<string, boolean>) {
return EntryRelatedService.upsert(EntryRelatedKey.READ, record)
}

View File

@ -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<string>,
): CombinedEntryModel | null =>
export const useEntry = (entryId: Nullable<string>): 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 = (

View File

@ -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<string, boolean>
const entryFeedMap = {} as Record<string, string>
const entryCollection = {} as Record<string, any>
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<string, any>
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, {

View File

@ -5,6 +5,7 @@ type FeedId = string
type EntryId = string
type EntriesIdTable = Record<FeedId, EntryId[]>
export type FlatEntryModel = Omit<CombinedEntryModel, "feeds"> & { 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<FeedId, CombinedEntryModel>
flatMapEntries: Record<FeedId, FlatEntryModel>
/**
* 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

View File

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

View File

@ -0,0 +1,3 @@
import type { SearchInstance } from "./types"
export const defineSearchInstance = (instance: SearchInstance) => instance

View File

@ -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<SearchState>("search")(createState)
const { getState: get, setState: set } = useSearchStore
class SearchActions {
reset() {
set(createState)
}
private createFuse<T extends object>(data: T[], keys: (keyof T)[]) {
const options: IFuseOptions<T> = {
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()

View File

@ -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<T extends object, A extends object = object>
extends A {
item: T
}
export interface SearchState {
feeds: SearchResult<FeedModel>[]
entries: SearchResult<EntryModel, { feedId: string }>[]
subscriptions: SearchResult<SubscriptionPlainModel, { feedId: string }>[]
keyword: string
searchType: SearchType
}
export interface SearchInstance {
search: (keyword: string) => SearchState
}

View File

@ -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<string, number>

View File

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