Merge remote-tracking branch 'origin/feat/local-search'
This commit is contained in:
commit
6ccc61c2de
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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]" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export type UniversalItemProps = {
|
|||
entryId: string
|
||||
entryPreview?: CombinedEntryModel & {
|
||||
feeds: FeedModel
|
||||
feedId: string
|
||||
}
|
||||
translation?: {
|
||||
title?: string
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,7 +103,6 @@ export function FeedList({
|
|||
navigate({
|
||||
entryId: null,
|
||||
feedId: null,
|
||||
level: levels.view,
|
||||
view,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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),
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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}`)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import type { SearchInstance } from "./types"
|
||||
|
||||
export const defineSearchInstance = (instance: SearchInstance) => instance
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in New Issue