feat(entry-content): enhance AI entry layout and improve subscription management
- Updated AIEntryLayout to include smoother animations for entry transitions. - Added feedId and entryId to entry title metadata for better context handling. - Refactored EntryTimeline component to accept additional className prop for styling flexibility. - Improved EntryContent component structure by replacing motion div with a standard div for better performance. - Enhanced entry read history and layout components for improved user experience. These changes collectively enhance the visual appeal and functionality of the entry content layout, providing a more seamless user experience. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
b55753918d
commit
db7b3ede20
|
|
@ -56,9 +56,9 @@ const AIEntryLayoutImpl = () => {
|
|||
<AnimatePresence mode="popLayout">
|
||||
{realEntryId && (
|
||||
<m.div
|
||||
initial={{ y: "100%" }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: "100%" }}
|
||||
initial={{ y: 150, opacity: 0, scale: 0.98 }}
|
||||
animate={{ y: 0, opacity: 1, scale: 1 }}
|
||||
exit={{ y: 150, opacity: 0, scale: 0.98 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className="bg-theme-background absolute inset-0 z-10 border-l"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,10 @@ export const [, , useEntryTitleMeta, , getEntryTitleMeta, setEntryTitleMeta] = c
|
|||
null as Nullable<{
|
||||
entryTitle: string
|
||||
feedTitle: string
|
||||
|
||||
// id-set
|
||||
feedId: string
|
||||
entryId: string
|
||||
}>,
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -20,12 +20,15 @@ export const EntryTimelineSidebar = ({ entryId }: { entryId: string }) => {
|
|||
return <EntryTimeline entryId={entryId} />
|
||||
}
|
||||
|
||||
export const EntryTimeline = ({ entryId }: { entryId: string }) => {
|
||||
export const EntryTimeline = ({ entryId, className }: { entryId: string; className?: string }) => {
|
||||
const entryIds = useGetEntryIdInRange(entryId, [5, 5])
|
||||
|
||||
return (
|
||||
<m.div
|
||||
className="@lg:hidden @6xl:block @6xl:max-w-[200px] @7xl:max-w-[200px] @[90rem]:max-w-[250px] absolute left-8 top-28 z-10"
|
||||
className={cn(
|
||||
"@lg:hidden @6xl:block @6xl:max-w-[200px] @7xl:max-w-[200px] @[90rem]:max-w-[250px] absolute left-8 top-28 z-10",
|
||||
className,
|
||||
)}
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { delay: 0.5 } }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -106,12 +106,7 @@ const EntryContentImpl: Component<EntryContentProps> = ({
|
|||
}, [animationController, entryId])
|
||||
|
||||
return (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={animationController}
|
||||
transition={Spring.presets.smooth}
|
||||
className={cn(className, "@container flex flex-col")}
|
||||
>
|
||||
<div className={cn(className, "@container flex flex-col")}>
|
||||
<EntryCommandShortcutRegister entryId={entryId} view={view} />
|
||||
<AIEntryHeader
|
||||
entryId={entryId}
|
||||
|
|
@ -130,37 +125,41 @@ const EntryContentImpl: Component<EntryContentProps> = ({
|
|||
scrollerRef={scrollerRef}
|
||||
/>
|
||||
</RootPortal>
|
||||
<EntryTimeline entryId={entryId} />
|
||||
<EntryTimeline entryId={entryId} className="top-48" />
|
||||
<EntryScrollArea scrollerRef={scrollerRef}>
|
||||
{/* Indicator for the entry */}
|
||||
<div className="select-text">
|
||||
{!isZenMode && isInHasTimelineView && (
|
||||
<>
|
||||
<div className="absolute inset-y-0 left-0 z-[9] flex w-12 items-center justify-center opacity-40 duration-200 hover:opacity-100">
|
||||
<MotionButtonBase
|
||||
// -12: Visual center point
|
||||
className="absolute left-0 shrink-0 !-translate-y-12 cursor-pointer"
|
||||
onClick={() => {
|
||||
EventBus.dispatch(COMMAND_ID.timeline.switchToPrevious)
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-left-small-sharp text-text-secondary size-16" />
|
||||
</MotionButtonBase>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-y-0 right-0 z-[9] flex w-12 items-center justify-center opacity-40 duration-200 hover:opacity-100">
|
||||
<MotionButtonBase
|
||||
className="absolute right-0 shrink-0 !-translate-y-12 cursor-pointer"
|
||||
onClick={() => {
|
||||
EventBus.dispatch(COMMAND_ID.timeline.switchToNext)
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-right-small-sharp text-text-secondary size-16" />
|
||||
</MotionButtonBase>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isZenMode && isInHasTimelineView && (
|
||||
<>
|
||||
<div className="absolute inset-y-0 left-0 z-[9] flex w-12 items-center justify-center opacity-40 duration-200 hover:opacity-100">
|
||||
<MotionButtonBase
|
||||
// -12: Visual center point
|
||||
className="absolute left-0 shrink-0 !-translate-y-12 cursor-pointer"
|
||||
onClick={() => {
|
||||
EventBus.dispatch(COMMAND_ID.timeline.switchToPrevious)
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-left-small-sharp text-text-secondary size-16" />
|
||||
</MotionButtonBase>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-y-0 right-0 z-[9] flex w-12 items-center justify-center opacity-40 duration-200 hover:opacity-100">
|
||||
<MotionButtonBase
|
||||
className="absolute right-0 shrink-0 !-translate-y-12 cursor-pointer"
|
||||
onClick={() => {
|
||||
EventBus.dispatch(COMMAND_ID.timeline.switchToNext)
|
||||
}}
|
||||
>
|
||||
<i className="i-mgc-right-small-sharp text-text-secondary size-16" />
|
||||
</MotionButtonBase>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<m.div
|
||||
className="select-text"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={animationController}
|
||||
transition={Spring.presets.smooth}
|
||||
>
|
||||
<article
|
||||
data-testid="entry-render"
|
||||
onContextMenu={stopPropagation}
|
||||
|
|
@ -195,13 +194,13 @@ const EntryContentImpl: Component<EntryContentProps> = ({
|
|||
/>
|
||||
)}
|
||||
</article>
|
||||
</div>
|
||||
</m.div>
|
||||
</EntryScrollArea>
|
||||
<SourceContentPanel src={safeUrl ?? "#"} />
|
||||
</Focusable>
|
||||
|
||||
{/* <React.Suspense>{!isInPeekModal && <AISmartSidebar entryId={entryId} />}</React.Suspense> */}
|
||||
</m.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export const EntryContent = memo(EntryContentImpl)
|
||||
|
|
@ -218,7 +217,9 @@ const EntryScrollArea: Component<{
|
|||
<ScrollArea.ScrollArea
|
||||
focusable
|
||||
mask={false}
|
||||
stopWheelPropagation={false}
|
||||
viewportProps={{
|
||||
onWheel: stopPropagation,
|
||||
}}
|
||||
flex
|
||||
rootClassName={cn(
|
||||
"flex-1 min-h-0 overflow-y-auto print:h-auto print:overflow-visible",
|
||||
|
|
|
|||
|
|
@ -253,7 +253,9 @@ const EntryScrollArea: Component<{
|
|||
<ScrollArea.ScrollArea
|
||||
focusable
|
||||
mask={false}
|
||||
stopWheelPropagation={false}
|
||||
viewportProps={{
|
||||
onWheel: stopPropagation,
|
||||
}}
|
||||
rootClassName={cn(
|
||||
"h-0 min-w-0 grow overflow-y-auto print:h-auto print:overflow-visible",
|
||||
className,
|
||||
|
|
|
|||
|
|
@ -30,11 +30,11 @@ export const EntryTitleMetaHandler: Component<{
|
|||
|
||||
useEffect(() => {
|
||||
if (entry?.title && feedTitle) {
|
||||
setEntryTitleMeta({ entryTitle: entry.title, feedTitle })
|
||||
setEntryTitleMeta({ entryTitle: entry.title, feedTitle, feedId: entry.feedId!, entryId })
|
||||
}
|
||||
return () => {
|
||||
setEntryTitleMeta(null)
|
||||
}
|
||||
}, [entryId, entry?.title, feedTitle])
|
||||
}, [entryId, entry?.title, feedTitle, entry?.feedId])
|
||||
return null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,21 @@
|
|||
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
|
||||
import { views } from "@follow/constants"
|
||||
import { getEntry, getEntryIdsByFeedId } from "@follow/store/entry/getter"
|
||||
import { useFeedById } from "@follow/store/feed/hooks"
|
||||
import { useListById } from "@follow/store/list/hooks"
|
||||
import {
|
||||
getFeedSubscriptionByViewSelector,
|
||||
getListSubscriptionByViewSelector,
|
||||
} from "@follow/store/subscription/getter"
|
||||
import type {
|
||||
useFeedSubscriptionByView,
|
||||
useListSubscriptionByView,
|
||||
} from "@follow/store/subscription/hooks"
|
||||
import { useSubscriptionStore } from "@follow/store/subscription/store"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { useForceUpdate } from "motion/react"
|
||||
import { useRef } from "react"
|
||||
import { useCallback, useRef } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -11,94 +24,265 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu/dropdown-menu"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { getRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { getPreferredTitle } from "~/store/feed/hooks"
|
||||
|
||||
import { useEntryContentScrollToTop, useEntryTitleMeta } from "../../../atoms"
|
||||
import { useEntryTitleMeta } from "../../../atoms"
|
||||
import { useEntryHeaderContext } from "./context"
|
||||
|
||||
export function EntryHeaderBreadcrumb() {
|
||||
const meta = useEntryTitleMeta()
|
||||
const isAtTop = useEntryContentScrollToTop()
|
||||
const navigate = useNavigateEntry()
|
||||
const { entryId } = useEntryHeaderContext()
|
||||
const Slash = (
|
||||
<i className="i-mingcute-line-line text-text-tertiary size-4 shrink-0 rotate-[-25deg]" />
|
||||
)
|
||||
|
||||
function ViewSubscriptionsDropdown({
|
||||
view,
|
||||
onNavigate,
|
||||
}: {
|
||||
view: number
|
||||
onNavigate: ReturnType<typeof useNavigateEntry>
|
||||
}) {
|
||||
const feedSubsRef = useRef<ReturnType<typeof useFeedSubscriptionByView>>([])
|
||||
const listSubsRef = useRef<ReturnType<typeof useListSubscriptionByView>>([])
|
||||
const [forceUpdate] = useForceUpdate()
|
||||
|
||||
const handleRefreshDropDownData = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) return
|
||||
|
||||
// Get fresh data from store
|
||||
const state = useSubscriptionStore.getState()
|
||||
const feedSubs = getFeedSubscriptionByViewSelector(state)(view)
|
||||
const listSubs = getListSubscriptionByViewSelector(state)(view)
|
||||
|
||||
feedSubsRef.current = feedSubs || []
|
||||
listSubsRef.current = listSubs || []
|
||||
|
||||
forceUpdate()
|
||||
},
|
||||
[view, forceUpdate],
|
||||
)
|
||||
|
||||
const routeParams = getRouteParams()
|
||||
const { isAllFeeds, listId, feedId } = routeParams
|
||||
|
||||
// Check if there's any subscription data for this view (without causing re-render)
|
||||
// This allows initial render to show the dropdown if subscriptions exist
|
||||
const state = useSubscriptionStore.getState()
|
||||
const initialFeedSubs = getFeedSubscriptionByViewSelector(state)(view)
|
||||
const initialListSubs = getListSubscriptionByViewSelector(state)(view)
|
||||
const hasAnyInitial = (initialFeedSubs?.length ?? 0) + (initialListSubs?.length ?? 0) > 0
|
||||
|
||||
if (!hasAnyInitial) return null
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={handleRefreshDropDownData}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-text-tertiary hover:text-text focus-visible:bg-fill/60 -ml-1 inline-flex size-6 items-center justify-center rounded transition-colors"
|
||||
aria-label="Open subscriptions of this view"
|
||||
>
|
||||
<i className="i-mingcute-down-line size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-0">
|
||||
<ScrollArea.ScrollArea
|
||||
flex
|
||||
rootClassName="max-h-[60vh] min-h-0 relative min-w-64"
|
||||
viewportClassName="max-h-[60vh]"
|
||||
>
|
||||
<div className="p-1">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onNavigate({ entryId: null, view })}
|
||||
checked={isAllFeeds}
|
||||
>
|
||||
<span className="truncate">All</span>
|
||||
</DropdownMenuItem>
|
||||
{listSubsRef.current && listSubsRef.current.length > 0 && (
|
||||
<div className="text-text-tertiary px-2 py-1 text-xs">Lists</div>
|
||||
)}
|
||||
{listSubsRef.current?.map((s) =>
|
||||
s.listId ? (
|
||||
<DropdownMenuItem
|
||||
checked={s.listId === listId}
|
||||
key={`list-${s.listId}`}
|
||||
onClick={() => s.listId && onNavigate({ entryId: null, listId: s.listId })}
|
||||
>
|
||||
<ListNameItem listId={s.listId} />
|
||||
</DropdownMenuItem>
|
||||
) : null,
|
||||
)}
|
||||
{feedSubsRef.current && feedSubsRef.current.length > 0 && (
|
||||
<div className="text-text-tertiary px-2 py-1 text-xs">Feeds</div>
|
||||
)}
|
||||
{feedSubsRef.current?.map((s) =>
|
||||
s.feedId ? (
|
||||
<DropdownMenuItem
|
||||
checked={s.feedId === feedId}
|
||||
key={`feed-${s.feedId}`}
|
||||
onClick={() => s.feedId && onNavigate({ entryId: null, feedId: s.feedId })}
|
||||
>
|
||||
<FeedNameItem feedId={s.feedId} />
|
||||
</DropdownMenuItem>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea.ScrollArea>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
const ListNameItem = ({ listId }: { listId: string }) => {
|
||||
const name = useListById(listId, (s) => s?.title)
|
||||
if (!name) return null
|
||||
return <span className="truncate">{name}</span>
|
||||
}
|
||||
|
||||
const FeedNameItem = ({ feedId }: { feedId: string }) => {
|
||||
const feed = useFeedById(feedId)
|
||||
|
||||
if (!feed) return null
|
||||
return <span className="truncate">{getPreferredTitle(feed)}</span>
|
||||
}
|
||||
|
||||
function FeedEntriesDropdown({
|
||||
feedId,
|
||||
currentEntryId,
|
||||
onNavigate,
|
||||
}: {
|
||||
feedId: string
|
||||
currentEntryId: string
|
||||
onNavigate: ReturnType<typeof useNavigateEntry>
|
||||
}) {
|
||||
const siblingEntriesRef = useRef<{ id: string; title: string }[]>([])
|
||||
const [forceUpdate] = useForceUpdate()
|
||||
|
||||
const handleRefreshDropDownData = () => {
|
||||
const entry = getEntry(entryId)
|
||||
if (!entry) return
|
||||
const { feedId } = entry
|
||||
const handleRefreshDropDownData = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) return
|
||||
|
||||
if (!feedId) return
|
||||
const entryIds = getEntryIdsByFeedId(feedId)
|
||||
if (!entryIds) return
|
||||
const entryIds = getEntryIdsByFeedId(feedId)
|
||||
if (!entryIds) return
|
||||
|
||||
siblingEntriesRef.current = []
|
||||
for (const entryId of entryIds) {
|
||||
const entry = getEntry(entryId)
|
||||
if (!entry) continue
|
||||
const { title } = entry
|
||||
if (!title) continue
|
||||
siblingEntriesRef.current.push({ id: entryId, title })
|
||||
}
|
||||
siblingEntriesRef.current = []
|
||||
for (const entryId of entryIds) {
|
||||
const entry = getEntry(entryId)
|
||||
if (!entry) continue
|
||||
const { title } = entry
|
||||
if (!title) continue
|
||||
siblingEntriesRef.current.push({ id: entryId, title })
|
||||
}
|
||||
|
||||
siblingEntriesRef.current = siblingEntriesRef.current.slice(0, 30)
|
||||
forceUpdate()
|
||||
},
|
||||
[feedId, forceUpdate],
|
||||
)
|
||||
|
||||
forceUpdate()
|
||||
}
|
||||
// Check if there are any entries for this feed
|
||||
const entryIds = getEntryIdsByFeedId(feedId)
|
||||
if (!entryIds || entryIds.length <= 1) return null
|
||||
|
||||
return (
|
||||
<DropdownMenu onOpenChange={handleRefreshDropDownData}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="text-text-tertiary hover:text-text focus-visible:bg-fill/60 -ml-2 inline-flex size-6 items-center justify-center rounded transition-colors"
|
||||
aria-label="Open entries from this feed"
|
||||
>
|
||||
<i className="i-mingcute-down-line size-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-0">
|
||||
<ScrollArea.ScrollArea
|
||||
rootClassName="max-h-[60vh] min-w-64"
|
||||
viewportClassName="max-h-[60vh]"
|
||||
>
|
||||
<div className="p-1">
|
||||
{siblingEntriesRef.current.map((e) => (
|
||||
<DropdownMenuItem
|
||||
key={e.id}
|
||||
onClick={() => onNavigate({ entryId: e.id })}
|
||||
checked={e.id === currentEntryId}
|
||||
>
|
||||
<span className="truncate" title={e.title}>
|
||||
{e.title}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea.ScrollArea>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export function EntryHeaderBreadcrumb() {
|
||||
const meta = useEntryTitleMeta()
|
||||
|
||||
const navigate = useNavigateEntry()
|
||||
const { entryId } = useEntryHeaderContext()
|
||||
|
||||
const { t } = useTranslation()
|
||||
const view = useRouteParamsSelector((s) => s.view)
|
||||
if (!meta) return null
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
className={cn(
|
||||
"text-text-secondary group/breadcrumb flex min-w-0 items-center gap-1 truncate leading-tight",
|
||||
!isAtTop && "text-text",
|
||||
)}
|
||||
className={
|
||||
"text-text-secondary group/breadcrumb flex min-w-0 items-center gap-1 truncate leading-tight"
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
{/* Return Back Button */}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"text-text-secondary hover:text-text hover:bg-fill/50 focus-visible:bg-fill/60 inline-flex max-w-[40vw] items-center truncate rounded bg-transparent px-1.5 py-0.5 text-sm transition-colors",
|
||||
)}
|
||||
className="text-text-secondary hover:text-text hover:bg-fill/50 focus-visible:bg-fill/60 inline-flex max-w-[40vw] shrink-0 items-center truncate rounded bg-transparent px-1.5 py-0.5 text-sm transition-colors"
|
||||
onClick={() => navigate({ entryId: null })}
|
||||
title={meta.feedTitle}
|
||||
>
|
||||
<span className="truncate">{meta.feedTitle}</span>
|
||||
<i className="i-mingcute-arrow-left-line size-4" />
|
||||
</button>
|
||||
|
||||
<DropdownMenu onOpenChange={handleRefreshDropDownData}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{views[view]?.name && (
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="text-text-tertiary hover:text-text focus-visible:bg-fill/60 -ml-2 inline-flex size-6 items-center justify-center rounded transition-colors"
|
||||
aria-label="Open entries from this feed"
|
||||
className={cn(
|
||||
"text-text-secondary hover:text-text hover:bg-fill/50 focus-visible:bg-fill/60 inline-flex max-w-[40vw] items-center truncate rounded bg-transparent px-1.5 py-0.5 text-sm transition-colors",
|
||||
)}
|
||||
onClick={() => navigate({ entryId: null, view })}
|
||||
>
|
||||
<i className="i-mingcute-down-line size-4" />
|
||||
<span className="text-text-secondary text-sm">
|
||||
{t(views[view]?.name, { ns: "common" })}
|
||||
</span>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="p-0">
|
||||
<ScrollArea.ScrollArea rootClassName="max-h-[60vh] min-w-64">
|
||||
<div className="p-1">
|
||||
{siblingEntriesRef.current.map((e) => (
|
||||
<DropdownMenuItem
|
||||
key={e.id}
|
||||
onClick={() => navigate({ entryId: e.id })}
|
||||
checked={e.id === entryId}
|
||||
>
|
||||
<span className="truncate" title={e.title}>
|
||||
{e.title}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea.ScrollArea>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<i className="i-mingcute-right-line text-text-tertiary size-4 shrink-0" />
|
||||
<ViewSubscriptionsDropdown view={view} onNavigate={navigate} />
|
||||
</div>
|
||||
)}
|
||||
{Slash}
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"text-text-secondary hover:text-text hover:bg-fill/50 focus-visible:bg-fill/60 inline-flex max-w-[40vw] items-center truncate rounded bg-transparent px-1.5 py-0.5 text-sm transition-colors",
|
||||
)}
|
||||
onClick={() => navigate({ entryId: null, feedId: meta.feedId })}
|
||||
title={meta.feedTitle}
|
||||
>
|
||||
<span className="truncate">{meta.feedTitle}</span>
|
||||
</button>
|
||||
|
||||
<FeedEntriesDropdown
|
||||
feedId={meta.feedId}
|
||||
currentEntryId={entryId}
|
||||
onNavigate={navigate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{Slash}
|
||||
|
||||
<span className="text-text truncate px-1.5 py-0.5 text-sm" title={meta.entryTitle}>
|
||||
{meta.entryTitle}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { Avatar, AvatarFallback, AvatarImage } from "@follow/components/ui/avata
|
|||
import { TooltipContent, TooltipPortal } from "@follow/components/ui/tooltip/index.jsx"
|
||||
import { useUserById } from "@follow/store/user/hooks"
|
||||
import { getNameInitials } from "@follow/utils/cjk"
|
||||
import { m } from "motion/react"
|
||||
import { memo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
|
|
@ -19,9 +18,7 @@ export const EntryUser: Component<{
|
|||
if (!user) return null
|
||||
return (
|
||||
<div className="no-drag-region relative cursor-pointer hover:!z-[99999]" ref={ref}>
|
||||
<m.button
|
||||
layout="position"
|
||||
layoutId={userId}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
presentUserProfile(userId)
|
||||
|
|
@ -34,7 +31,7 @@ export const EntryUser: Component<{
|
|||
/>
|
||||
<AvatarFallback>{getNameInitials(user.name || "")}</AvatarFallback>
|
||||
</Avatar>
|
||||
</m.button>
|
||||
</button>
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="top">
|
||||
{t("entry_actions.recent_reader")} {user.name}
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ const Renderer: React.FC<{
|
|||
|
||||
const tocRef = useRef<TocRef | null>(null)
|
||||
const contentAccessories = useMemo(
|
||||
() => (isInPeekModal ? undefined : <ContainerToc ref={tocRef} stickyClassName="top-28" />),
|
||||
() => (isInPeekModal ? undefined : <ContainerToc ref={tocRef} stickyClassName="top-48" />),
|
||||
[isInPeekModal],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { stopPropagation } from "@follow/utils/dom"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import * as ScrollAreaBase from "@radix-ui/react-scroll-area"
|
||||
import * as React from "react"
|
||||
|
|
@ -152,7 +151,8 @@ export const ScrollArea = ({
|
|||
asChild = false,
|
||||
onUpdateMaxScroll,
|
||||
focusable = true,
|
||||
stopWheelPropagation = true,
|
||||
|
||||
viewportProps,
|
||||
}: React.PropsWithChildren & {
|
||||
rootClassName?: string
|
||||
viewportClassName?: string
|
||||
|
|
@ -164,7 +164,7 @@ export const ScrollArea = ({
|
|||
orientation?: "vertical" | "horizontal"
|
||||
asChild?: boolean
|
||||
focusable?: boolean
|
||||
stopWheelPropagation?: boolean
|
||||
viewportProps?: React.ComponentProps<typeof ScrollAreaBase.Viewport>
|
||||
} & { ref?: React.Ref<HTMLDivElement | null> }) => {
|
||||
const [viewportRef, setViewportRef] = React.useState<HTMLDivElement | null>(null)
|
||||
React.useImperativeHandle(ref, () => viewportRef as HTMLDivElement)
|
||||
|
|
@ -177,7 +177,6 @@ export const ScrollArea = ({
|
|||
<Root className={rootClassName} flex={flex}>
|
||||
<Viewport
|
||||
ref={setViewportRef}
|
||||
onWheel={stopWheelPropagation ? stopPropagation : undefined}
|
||||
className={cn(
|
||||
flex && "[&>div]:!flex [&>div]:!min-h-0 [&>div]:!flex-col", // Add min-h-0 to flex children
|
||||
viewportClassName,
|
||||
|
|
@ -186,6 +185,7 @@ export const ScrollArea = ({
|
|||
asChild={asChild}
|
||||
onScroll={onScroll}
|
||||
focusable={focusable}
|
||||
{...viewportProps}
|
||||
>
|
||||
{children}
|
||||
</Viewport>
|
||||
|
|
|
|||
|
|
@ -149,7 +149,7 @@ export function useEntry<T>(
|
|||
})
|
||||
}
|
||||
export const useHasEntry = (id: string) => {
|
||||
return useEntryStore((state) => getHasEntrySelector(state)(id))
|
||||
return useEntryStore(useCallback((state) => getHasEntrySelector(state)(id), [id]))
|
||||
}
|
||||
export function useEntryList(ids: string[]): Array<EntryModel | null>
|
||||
export function useEntryList<T>(ids: string[], selector: (state: EntryModel) => T): T[] | undefined
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import { FeedViewType } from "@follow/constants"
|
||||
import { sortByAlphabet } from "@follow/utils/utils"
|
||||
|
||||
import { createSingleArgGetter, createStaticGetter } from "../../lib/helper"
|
||||
import { getEntry } from "../entry/getter"
|
||||
import { getFeedById } from "../feed/getter"
|
||||
import { getInboxList } from "../inbox/getters"
|
||||
import { getListFeedIds } from "../list/getters"
|
||||
import { getListById, getListFeedIds } from "../list/getters"
|
||||
import { getUnreadById, getUnreadByListId } from "../unread/getters"
|
||||
import { folderFeedsByFeedIdSelector } from "./selectors"
|
||||
import { useSubscriptionStore } from "./store"
|
||||
import { getDefaultCategory } from "./utils"
|
||||
|
|
@ -90,3 +94,401 @@ export const getCategoryFeedIds = (category: string, view: FeedViewType): string
|
|||
}
|
||||
return feedIds
|
||||
}
|
||||
|
||||
// Utility functions for creating getters
|
||||
type StateType = ReturnType<typeof useSubscriptionStore.getState>
|
||||
const getState = () => useSubscriptionStore.getState()
|
||||
|
||||
// Helper functions for sorting
|
||||
const sortUngroupedSubscriptionByAlphabet = (
|
||||
leftSubscriptionId: string,
|
||||
rightSubscriptionId: string,
|
||||
) => {
|
||||
const leftSubscription = getSubscriptionById(leftSubscriptionId)
|
||||
const rightSubscription = getSubscriptionById(rightSubscriptionId)
|
||||
|
||||
if (!leftSubscription || !rightSubscription) return 0
|
||||
|
||||
if (!leftSubscription.feedId || !rightSubscription.feedId) return 0
|
||||
const leftFeed = getFeedById(leftSubscription.feedId)
|
||||
const rightFeed = getFeedById(rightSubscription.feedId)
|
||||
|
||||
if (!leftFeed || !rightFeed) return 0
|
||||
|
||||
const comparedLeftTitle = leftSubscription.title || leftFeed.title!
|
||||
const comparedRightTitle = rightSubscription.title || rightFeed.title!
|
||||
|
||||
return sortByAlphabet(comparedLeftTitle, comparedRightTitle)
|
||||
}
|
||||
|
||||
const sortByUnread = (leftSubscriptionId: string, rightSubscriptionId: string) => {
|
||||
const leftSubscription = getSubscriptionById(leftSubscriptionId)
|
||||
const rightSubscription = getSubscriptionById(rightSubscriptionId)
|
||||
|
||||
const nextLeftSubscriptionId = leftSubscription?.feedId || leftSubscription?.listId
|
||||
const nextRightSubscriptionId = rightSubscription?.feedId || rightSubscription?.listId
|
||||
|
||||
if (!nextLeftSubscriptionId || !nextRightSubscriptionId) return 0
|
||||
return getUnreadById(nextRightSubscriptionId) - getUnreadById(nextLeftSubscriptionId)
|
||||
}
|
||||
|
||||
const sortGroupedSubscriptionByUnread = (
|
||||
leftCategory: string,
|
||||
rightCategory: string,
|
||||
view: FeedViewType,
|
||||
) => {
|
||||
const leftFeedIds = getSubscriptionByCategory({ category: leftCategory, view })
|
||||
const rightFeedIds = getSubscriptionByCategory({ category: rightCategory, view })
|
||||
|
||||
const leftUnreadCount = leftFeedIds.reduce((acc, feedId) => {
|
||||
return acc + getUnreadById(feedId)
|
||||
}, 0)
|
||||
const rightUnreadCount = rightFeedIds.reduce((acc, feedId) => {
|
||||
return acc + getUnreadById(feedId)
|
||||
}, 0)
|
||||
return -(rightUnreadCount - leftUnreadCount)
|
||||
}
|
||||
|
||||
// Store selector functions (for React hooks)
|
||||
export const getSubscriptionIdsByViewSelector = (state: StateType) => (view: FeedViewType) => {
|
||||
return Array.from(state.feedIdByView[view])
|
||||
.concat(view === FeedViewType.Articles ? getInboxList().map((i) => i.id) : [])
|
||||
.concat(Array.from(state.listIdByView[view]).flatMap((id) => getListFeedIds(id) ?? []))
|
||||
}
|
||||
|
||||
export const getFeedSubscriptionIdsByViewSelector =
|
||||
(state: StateType) => (view: FeedViewType | undefined) => {
|
||||
return typeof view === "number" ? Array.from(state.feedIdByView[view]) : []
|
||||
}
|
||||
|
||||
export const getFeedSubscriptionByViewSelector = (state: StateType) => (view: FeedViewType) => {
|
||||
return Array.from(state.feedIdByView[view])
|
||||
.map((feedId) => state.data[feedId])
|
||||
.filter((feed) => !!feed)
|
||||
}
|
||||
|
||||
export const getListSubscriptionByViewSelector = (state: StateType) => (view: FeedViewType) => {
|
||||
return Array.from(state.listIdByView[view])
|
||||
.map((listId) => state.data[listId])
|
||||
.filter((list) => !!list)
|
||||
}
|
||||
|
||||
export const getGroupedSubscriptionSelector =
|
||||
(state: StateType) =>
|
||||
({ view, autoGroup }: { view: FeedViewType; autoGroup: boolean }) => {
|
||||
const feedIds = state.feedIdByView[view]
|
||||
|
||||
const grouped = {} as Record<string, string[]>
|
||||
const unGrouped = [] as string[]
|
||||
|
||||
const autoGrouped = {} as Record<string, string[]>
|
||||
|
||||
for (const feedId of feedIds) {
|
||||
const subscription = state.data[feedId]
|
||||
if (!subscription) continue
|
||||
const { category } = subscription
|
||||
if (!category) {
|
||||
const defaultCategory = getDefaultCategory(subscription)
|
||||
if (defaultCategory && autoGroup) {
|
||||
if (!autoGrouped[defaultCategory]) {
|
||||
autoGrouped[defaultCategory] = []
|
||||
}
|
||||
autoGrouped[defaultCategory].push(feedId)
|
||||
} else {
|
||||
unGrouped.push(feedId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!grouped[category]) {
|
||||
grouped[category] = []
|
||||
}
|
||||
grouped[category].push(feedId)
|
||||
}
|
||||
|
||||
if (autoGroup) {
|
||||
for (const category of Object.keys(autoGrouped)) {
|
||||
if (autoGrouped[category] && autoGrouped[category].length > 1) {
|
||||
grouped[category] = autoGrouped[category]
|
||||
} else {
|
||||
unGrouped.push(...autoGrouped[category]!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
grouped,
|
||||
unGrouped,
|
||||
}
|
||||
}
|
||||
|
||||
export const getSortedGroupedSubscriptionSelector =
|
||||
(_state: StateType) =>
|
||||
({
|
||||
view,
|
||||
grouped,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
hideAllReadSubscriptions,
|
||||
}: {
|
||||
view: FeedViewType
|
||||
grouped: Record<string, string[]>
|
||||
sortBy: "alphabet" | "count"
|
||||
sortOrder: "asc" | "desc"
|
||||
hideAllReadSubscriptions: boolean
|
||||
}) => {
|
||||
const categories = Object.keys(grouped)
|
||||
const sortedCategories = categories.sort((a, b) => {
|
||||
const sortMethod = sortBy === "alphabet" ? sortByAlphabet : sortGroupedSubscriptionByUnread
|
||||
const result = sortMethod(a, b, view)
|
||||
return sortOrder === "asc" ? result : -result
|
||||
})
|
||||
const sortedList = [] as { category: string; subscriptionIds: string[] }[]
|
||||
for (const category of sortedCategories) {
|
||||
if (!hideAllReadSubscriptions || grouped[category]?.some((id) => getUnreadById(id) > 0)) {
|
||||
sortedList.push({ category, subscriptionIds: grouped[category]! })
|
||||
}
|
||||
}
|
||||
return sortedList
|
||||
}
|
||||
|
||||
export const getSortedUngroupedSubscriptionSelector =
|
||||
(_state: StateType) =>
|
||||
({
|
||||
ids,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
hideAllReadSubscriptions,
|
||||
}: {
|
||||
ids: string[]
|
||||
sortBy: "alphabet" | "count"
|
||||
sortOrder: "asc" | "desc"
|
||||
hideAllReadSubscriptions: boolean
|
||||
}) => {
|
||||
return ids
|
||||
.filter((id) => {
|
||||
return !hideAllReadSubscriptions || getUnreadById(id) > 0
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const sortMethod =
|
||||
sortBy === "alphabet" ? sortUngroupedSubscriptionByAlphabet : sortByUnread
|
||||
const result = sortMethod(a, b)
|
||||
return sortOrder === "asc" ? result : -result
|
||||
})
|
||||
}
|
||||
|
||||
export const getSortedFeedSubscriptionByAlphabetSelector =
|
||||
(_state: StateType) => (ids: string[]) => {
|
||||
return ids.sort((a, b) => {
|
||||
const leftFeed = getFeedById(a)
|
||||
const rightFeed = getFeedById(b)
|
||||
if (!leftFeed || !rightFeed) return 0
|
||||
return sortByAlphabet(leftFeed.title!, rightFeed.title!)
|
||||
})
|
||||
}
|
||||
|
||||
export const getSubscriptionByIdSelector =
|
||||
(state: StateType) => (id: string | undefined | null) => {
|
||||
return id ? state.data[id] : undefined
|
||||
}
|
||||
|
||||
export const getSubscriptionsByIdsSelector = (state: StateType) => (ids: string[]) => {
|
||||
return ids.map((id) => state.data[id])
|
||||
}
|
||||
|
||||
export const getAllListSubscriptionSelector = (state: StateType) => () => {
|
||||
return Object.values(state.listIdByView).flatMap((list) => Array.from(list))
|
||||
}
|
||||
|
||||
export const getListSubscriptionSelector = (state: StateType) => (view: FeedViewType) => {
|
||||
return Array.from(state.listIdByView[view]).map((listId) => state.data[listId])
|
||||
}
|
||||
|
||||
export const getListSubscriptionIdsSelector = (state: StateType) => (view: FeedViewType) => {
|
||||
return Array.from(state.listIdByView[view])
|
||||
}
|
||||
|
||||
export const getFeedSubscriptionSelector = (state: StateType) => (view: FeedViewType) => {
|
||||
return Array.from(state.feedIdByView[view]).map((feedId) => state.data[feedId])
|
||||
}
|
||||
|
||||
export const getFeedSubscriptionIdsSelector = (state: StateType) => (view: FeedViewType) => {
|
||||
return Array.from(state.feedIdByView[view])
|
||||
}
|
||||
|
||||
export const getAllFeedSubscriptionSelector = (state: StateType) => () => {
|
||||
return Object.values(state.feedIdByView).flatMap((feedId) =>
|
||||
Array.from(feedId)
|
||||
.map((id) => state.data[id])
|
||||
.filter((feed) => !!feed),
|
||||
)
|
||||
}
|
||||
|
||||
export const getAllFeedSubscriptionIdsSelector = (state: StateType) => () => {
|
||||
return Object.values(state.feedIdByView).flatMap((feedId) => Array.from(feedId))
|
||||
}
|
||||
|
||||
export const getAllSubscriptionSelector = (state: StateType) => () => {
|
||||
return Object.values(state.data).filter((subscription) => !!subscription)
|
||||
}
|
||||
|
||||
export const getSortedListSubscriptionSelector =
|
||||
(_state: StateType) =>
|
||||
({
|
||||
ids,
|
||||
sortBy,
|
||||
hideAllReadSubscriptions,
|
||||
}: {
|
||||
ids: string[]
|
||||
sortBy: "alphabet" | "unread"
|
||||
hideAllReadSubscriptions: boolean
|
||||
}) => {
|
||||
return ids
|
||||
.concat()
|
||||
.filter((id) => !hideAllReadSubscriptions || getUnreadByListId(id) > 0)
|
||||
.sort((a, b) => {
|
||||
const leftList = getListById(a)
|
||||
const rightList = getListById(b)
|
||||
if (!leftList || !rightList) return 0
|
||||
if (sortBy === "alphabet") {
|
||||
return sortByAlphabet(leftList.title || "", rightList.title || "")
|
||||
}
|
||||
return sortByUnread(a, b)
|
||||
})
|
||||
}
|
||||
|
||||
export const getCategoriesSelector = (state: StateType) => (view?: FeedViewType) => {
|
||||
return view === undefined
|
||||
? Array.from(
|
||||
new Set(Object.values(state.categories).flatMap((category) => Array.from(category))),
|
||||
)
|
||||
: Array.from(state.categories[view])
|
||||
}
|
||||
|
||||
export const getSubscriptionCategoryExistSelector =
|
||||
(state: StateType) => (categoryId: string | undefined | null) => {
|
||||
if (!categoryId) return false
|
||||
return Object.values(state.categories).some((category) => category.has(categoryId))
|
||||
}
|
||||
|
||||
export const getCategoriesByViewSelector = (state: StateType) => (view: FeedViewType) => {
|
||||
return state.categories[view]
|
||||
}
|
||||
|
||||
export const getListSubscriptionCountSelector = (state: StateType) => () => {
|
||||
return Array.from(state.subscriptionIdSet).filter((id) => id.startsWith("list/")).length
|
||||
}
|
||||
|
||||
export const getFeedSubscriptionCountSelector = (state: StateType) => () => {
|
||||
return Array.from(state.subscriptionIdSet).filter((id) => id.startsWith("feed/")).length
|
||||
}
|
||||
|
||||
export const getIsSubscribedSelector = (state: StateType) => (id: string | undefined) => {
|
||||
if (!id) return false
|
||||
return (
|
||||
state.subscriptionIdSet.has(id) ||
|
||||
state.subscriptionIdSet.has(`feed/${id}`) ||
|
||||
state.subscriptionIdSet.has(`list/${id}`) ||
|
||||
state.subscriptionIdSet.has(`inbox/${id}`)
|
||||
)
|
||||
}
|
||||
|
||||
export const getIsListSubscriptionSelector = (state: StateType) => (id: string | undefined) => {
|
||||
if (!id) return false
|
||||
return state.subscriptionIdSet.has(`list/${id}`)
|
||||
}
|
||||
|
||||
export const getNonPrivateSubscriptionIdsSelector = (state: StateType) => (ids: string[]) => {
|
||||
return ids
|
||||
.map((id) => state.data[id])
|
||||
.filter((s) => !s?.isPrivate)
|
||||
.map((s) => s?.listId || s?.feedId)
|
||||
.filter((id) => typeof id === "string")
|
||||
}
|
||||
|
||||
export const getCategoryOpenStateByViewSelector = (state: StateType) => (view: FeedViewType) => {
|
||||
return state.categoryOpenStateByView[view]
|
||||
}
|
||||
|
||||
// Static getters for use outside React components
|
||||
export const getSubscriptionIdsByView = createSingleArgGetter(
|
||||
getState,
|
||||
getSubscriptionIdsByViewSelector,
|
||||
)
|
||||
export const getFeedSubscriptionIdsByView = createSingleArgGetter(
|
||||
getState,
|
||||
getFeedSubscriptionIdsByViewSelector,
|
||||
)
|
||||
export const getFeedSubscriptionByView = createSingleArgGetter(
|
||||
getState,
|
||||
getFeedSubscriptionByViewSelector,
|
||||
)
|
||||
export const getListSubscriptionByView = createSingleArgGetter(
|
||||
getState,
|
||||
getListSubscriptionByViewSelector,
|
||||
)
|
||||
export const getGroupedSubscription = createSingleArgGetter(
|
||||
getState,
|
||||
getGroupedSubscriptionSelector,
|
||||
)
|
||||
export const getSortedGroupedSubscription = createSingleArgGetter(
|
||||
getState,
|
||||
getSortedGroupedSubscriptionSelector,
|
||||
)
|
||||
export const getSortedUngroupedSubscription = createSingleArgGetter(
|
||||
getState,
|
||||
getSortedUngroupedSubscriptionSelector,
|
||||
)
|
||||
export const getSortedFeedSubscriptionByAlphabet = createSingleArgGetter(
|
||||
getState,
|
||||
getSortedFeedSubscriptionByAlphabetSelector,
|
||||
)
|
||||
export const getSubscriptionByIdStatic = createSingleArgGetter(
|
||||
getState,
|
||||
getSubscriptionByIdSelector,
|
||||
)
|
||||
export const getSubscriptionsByIds = createSingleArgGetter(getState, getSubscriptionsByIdsSelector)
|
||||
export const getAllListSubscription = createStaticGetter(getState, getAllListSubscriptionSelector)
|
||||
export const getListSubscription = createSingleArgGetter(getState, getListSubscriptionSelector)
|
||||
export const getListSubscriptionIds = createSingleArgGetter(
|
||||
getState,
|
||||
getListSubscriptionIdsSelector,
|
||||
)
|
||||
export const getFeedSubscription = createSingleArgGetter(getState, getFeedSubscriptionSelector)
|
||||
export const getFeedSubscriptionIds = createSingleArgGetter(
|
||||
getState,
|
||||
getFeedSubscriptionIdsSelector,
|
||||
)
|
||||
export const getAllFeedSubscription = createStaticGetter(getState, getAllFeedSubscriptionSelector)
|
||||
export const getAllFeedSubscriptionIds = createStaticGetter(
|
||||
getState,
|
||||
getAllFeedSubscriptionIdsSelector,
|
||||
)
|
||||
export const getAllSubscription = createStaticGetter(getState, getAllSubscriptionSelector)
|
||||
export const getSortedListSubscription = createSingleArgGetter(
|
||||
getState,
|
||||
getSortedListSubscriptionSelector,
|
||||
)
|
||||
export const getCategories = createSingleArgGetter(getState, getCategoriesSelector)
|
||||
export const getSubscriptionCategoryExist = createSingleArgGetter(
|
||||
getState,
|
||||
getSubscriptionCategoryExistSelector,
|
||||
)
|
||||
export const getCategoriesByView = createSingleArgGetter(getState, getCategoriesByViewSelector)
|
||||
export const getListSubscriptionCount = createStaticGetter(
|
||||
getState,
|
||||
getListSubscriptionCountSelector,
|
||||
)
|
||||
export const getFeedSubscriptionCount = createStaticGetter(
|
||||
getState,
|
||||
getFeedSubscriptionCountSelector,
|
||||
)
|
||||
export const getIsSubscribed = createSingleArgGetter(getState, getIsSubscribedSelector)
|
||||
export const getIsListSubscription = createSingleArgGetter(getState, getIsListSubscriptionSelector)
|
||||
export const getNonPrivateSubscriptionIds = createSingleArgGetter(
|
||||
getState,
|
||||
getNonPrivateSubscriptionIdsSelector,
|
||||
)
|
||||
export const getCategoryOpenStateByView = createSingleArgGetter(
|
||||
getState,
|
||||
getCategoryOpenStateByViewSelector,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,14 +1,39 @@
|
|||
import { FeedViewType, views } from "@follow/constants"
|
||||
import { sortByAlphabet } from "@follow/utils/utils"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useCallback, useMemo } from "react"
|
||||
import { useCallback, useMemo, useRef } from "react"
|
||||
|
||||
import { getFeedById } from "../feed/getter"
|
||||
import { getInboxList } from "../inbox/getters"
|
||||
import { getListById, getListFeedIds } from "../list/getters"
|
||||
import { getUnreadById, getUnreadByListId } from "../unread/getters"
|
||||
import { getSubscriptionByCategory, getSubscriptionById } from "./getter"
|
||||
import {
|
||||
getAllFeedSubscriptionIdsSelector,
|
||||
getAllFeedSubscriptionSelector,
|
||||
getAllListSubscriptionSelector,
|
||||
getAllSubscriptionSelector,
|
||||
getCategoriesByViewSelector,
|
||||
getCategoriesSelector,
|
||||
getCategoryOpenStateByViewSelector,
|
||||
getFeedSubscriptionByViewSelector,
|
||||
getFeedSubscriptionCountSelector,
|
||||
getFeedSubscriptionIdsByViewSelector,
|
||||
getFeedSubscriptionIdsSelector,
|
||||
getFeedSubscriptionSelector,
|
||||
getGroupedSubscriptionSelector,
|
||||
getIsListSubscriptionSelector,
|
||||
getIsSubscribedSelector,
|
||||
getListSubscriptionByViewSelector,
|
||||
getListSubscriptionCountSelector,
|
||||
getListSubscriptionIdsSelector,
|
||||
getListSubscriptionSelector,
|
||||
getNonPrivateSubscriptionIdsSelector,
|
||||
getSortedFeedSubscriptionByAlphabetSelector,
|
||||
getSortedGroupedSubscriptionSelector,
|
||||
getSortedListSubscriptionSelector,
|
||||
getSortedUngroupedSubscriptionSelector,
|
||||
getSubscriptionByIdSelector,
|
||||
getSubscriptionCategoryExistSelector,
|
||||
getSubscriptionIdsByViewSelector,
|
||||
getSubscriptionsByIdsSelector,
|
||||
} from "./getter"
|
||||
import { folderFeedsByFeedIdSelector } from "./selectors"
|
||||
import type { SubscriptionState } from "./store"
|
||||
import { subscriptionSyncService, useSubscriptionStore } from "./store"
|
||||
import { getDefaultCategory } from "./utils"
|
||||
|
||||
|
|
@ -20,74 +45,27 @@ export const usePrefetchSubscription = (view?: FeedViewType) => {
|
|||
})
|
||||
}
|
||||
|
||||
const sortUngroupedSubscriptionByAlphabet = (
|
||||
leftSubscriptionId: string,
|
||||
rightSubscriptionId: string,
|
||||
) => {
|
||||
const leftSubscription = getSubscriptionById(leftSubscriptionId)
|
||||
const rightSubscription = getSubscriptionById(rightSubscriptionId)
|
||||
|
||||
if (!leftSubscription || !rightSubscription) return 0
|
||||
|
||||
if (!leftSubscription.feedId || !rightSubscription.feedId) return 0
|
||||
const leftFeed = getFeedById(leftSubscription.feedId)
|
||||
const rightFeed = getFeedById(rightSubscription.feedId)
|
||||
|
||||
if (!leftFeed || !rightFeed) return 0
|
||||
|
||||
const comparedLeftTitle = leftSubscription.title || leftFeed.title!
|
||||
const comparedRightTitle = rightSubscription.title || rightFeed.title!
|
||||
|
||||
return sortByAlphabet(comparedLeftTitle, comparedRightTitle)
|
||||
}
|
||||
|
||||
export const useSubscriptionIdsByView = (view: FeedViewType) => {
|
||||
return useSubscriptionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
return Array.from(state.feedIdByView[view])
|
||||
.concat(view === FeedViewType.Articles ? getInboxList().map((i) => i.id) : [])
|
||||
.concat(Array.from(state.listIdByView[view]).flatMap((id) => getListFeedIds(id) ?? []))
|
||||
},
|
||||
[view],
|
||||
),
|
||||
useCallback((state) => getSubscriptionIdsByViewSelector(state)(view), [view]),
|
||||
)
|
||||
}
|
||||
|
||||
export const useFeedSubscriptionIdsByView = (view: FeedViewType | undefined) => {
|
||||
return useSubscriptionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
return typeof view === "number" ? Array.from(state.feedIdByView[view]) : []
|
||||
},
|
||||
[view],
|
||||
),
|
||||
useCallback((state) => getFeedSubscriptionIdsByViewSelector(state)(view), [view]),
|
||||
)
|
||||
}
|
||||
|
||||
export const useFeedSubscriptionByView = (view: FeedViewType) => {
|
||||
return useSubscriptionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
return Array.from(state.feedIdByView[view])
|
||||
.map((feedId) => state.data[feedId])
|
||||
.filter((feed) => !!feed)
|
||||
},
|
||||
[view],
|
||||
),
|
||||
useCallback((state) => getFeedSubscriptionByViewSelector(state)(view), [view]),
|
||||
)
|
||||
}
|
||||
|
||||
export const useListSubscriptionByView = (view: FeedViewType) => {
|
||||
return useSubscriptionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
return Array.from(state.listIdByView[view])
|
||||
.map((listId) => state.data[listId])
|
||||
.filter((list) => !!list)
|
||||
},
|
||||
[view],
|
||||
),
|
||||
useCallback((state) => getListSubscriptionByViewSelector(state)(view), [view]),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -100,84 +78,12 @@ export const useGroupedSubscription = ({
|
|||
}) => {
|
||||
return useSubscriptionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
const feedIds = state.feedIdByView[view]
|
||||
|
||||
const grouped = {} as Record<string, string[]>
|
||||
const unGrouped = [] as string[]
|
||||
|
||||
const autoGrouped = {} as Record<string, string[]>
|
||||
|
||||
for (const feedId of feedIds) {
|
||||
const subscription = state.data[feedId]
|
||||
if (!subscription) continue
|
||||
const { category } = subscription
|
||||
if (!category) {
|
||||
const defaultCategory = getDefaultCategory(subscription)
|
||||
if (defaultCategory && autoGroup) {
|
||||
if (!autoGrouped[defaultCategory]) {
|
||||
autoGrouped[defaultCategory] = []
|
||||
}
|
||||
autoGrouped[defaultCategory].push(feedId)
|
||||
} else {
|
||||
unGrouped.push(feedId)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (!grouped[category]) {
|
||||
grouped[category] = []
|
||||
}
|
||||
grouped[category].push(feedId)
|
||||
}
|
||||
|
||||
if (autoGroup) {
|
||||
for (const category of Object.keys(autoGrouped)) {
|
||||
if (autoGrouped[category] && autoGrouped[category].length > 1) {
|
||||
grouped[category] = autoGrouped[category]
|
||||
} else {
|
||||
unGrouped.push(...autoGrouped[category]!)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
grouped,
|
||||
unGrouped,
|
||||
}
|
||||
},
|
||||
(state) => getGroupedSubscriptionSelector(state)({ view, autoGroup }),
|
||||
[autoGroup, view],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const sortByUnread = (_leftSubscriptionId: string, _rightSubscriptionId: string) => {
|
||||
const leftSubscription = getSubscriptionById(_leftSubscriptionId)
|
||||
const rightSubscription = getSubscriptionById(_rightSubscriptionId)
|
||||
|
||||
const leftSubscriptionId = leftSubscription?.feedId || leftSubscription?.listId
|
||||
const rightSubscriptionId = rightSubscription?.feedId || rightSubscription?.listId
|
||||
|
||||
if (!leftSubscriptionId || !rightSubscriptionId) return 0
|
||||
return getUnreadById(rightSubscriptionId) - getUnreadById(leftSubscriptionId)
|
||||
}
|
||||
|
||||
const sortGroupedSubscriptionByUnread = (
|
||||
leftCategory: string,
|
||||
rightCategory: string,
|
||||
view: FeedViewType,
|
||||
) => {
|
||||
const leftFeedIds = getSubscriptionByCategory({ category: leftCategory, view })
|
||||
const rightFeedIds = getSubscriptionByCategory({ category: rightCategory, view })
|
||||
|
||||
const leftUnreadCount = leftFeedIds.reduce((acc, feedId) => {
|
||||
return acc + getUnreadById(feedId)
|
||||
}, 0)
|
||||
const rightUnreadCount = rightFeedIds.reduce((acc, feedId) => {
|
||||
return acc + getUnreadById(feedId)
|
||||
}, 0)
|
||||
return -(rightUnreadCount - leftUnreadCount)
|
||||
}
|
||||
|
||||
export const useSortedGroupedSubscription = ({
|
||||
view,
|
||||
grouped,
|
||||
|
|
@ -192,21 +98,18 @@ export const useSortedGroupedSubscription = ({
|
|||
hideAllReadSubscriptions: boolean
|
||||
}) => {
|
||||
return useSubscriptionStore(
|
||||
useCallback(() => {
|
||||
const categories = Object.keys(grouped)
|
||||
const sortedCategories = categories.sort((a, b) => {
|
||||
const sortMethod = sortBy === "alphabet" ? sortByAlphabet : sortGroupedSubscriptionByUnread
|
||||
const result = sortMethod(a, b, view)
|
||||
return sortOrder === "asc" ? result : -result
|
||||
})
|
||||
const sortedList = [] as { category: string; subscriptionIds: string[] }[]
|
||||
for (const category of sortedCategories) {
|
||||
if (!hideAllReadSubscriptions || grouped[category]?.some((id) => getUnreadById(id) > 0)) {
|
||||
sortedList.push({ category, subscriptionIds: grouped[category]! })
|
||||
}
|
||||
}
|
||||
return sortedList
|
||||
}, [grouped, sortBy, sortOrder, view, hideAllReadSubscriptions]),
|
||||
useCallback(
|
||||
(state) => {
|
||||
return getSortedGroupedSubscriptionSelector(state)({
|
||||
view,
|
||||
grouped,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
hideAllReadSubscriptions,
|
||||
})
|
||||
},
|
||||
[grouped, sortBy, sortOrder, view, hideAllReadSubscriptions],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -222,51 +125,41 @@ export const useSortedUngroupedSubscription = ({
|
|||
hideAllReadSubscriptions: boolean
|
||||
}) => {
|
||||
return useSubscriptionStore(
|
||||
useCallback(() => {
|
||||
return ids
|
||||
.filter((id) => {
|
||||
return !hideAllReadSubscriptions || getUnreadById(id) > 0
|
||||
useCallback(
|
||||
(state) => {
|
||||
return getSortedUngroupedSubscriptionSelector(state)({
|
||||
ids,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
hideAllReadSubscriptions,
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const sortMethod =
|
||||
sortBy === "alphabet" ? sortUngroupedSubscriptionByAlphabet : sortByUnread
|
||||
const result = sortMethod(a, b)
|
||||
return sortOrder === "asc" ? result : -result
|
||||
})
|
||||
}, [ids.toString(), sortBy, sortOrder, hideAllReadSubscriptions]),
|
||||
},
|
||||
[ids, sortBy, sortOrder, hideAllReadSubscriptions],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const useSortedFeedSubscriptionByAlphabet = (ids: string[]) => {
|
||||
return useSubscriptionStore(
|
||||
useCallback(() => {
|
||||
return ids.sort((a, b) => {
|
||||
const leftFeed = getFeedById(a)
|
||||
const rightFeed = getFeedById(b)
|
||||
if (!leftFeed || !rightFeed) return 0
|
||||
return sortByAlphabet(leftFeed.title!, rightFeed.title!)
|
||||
})
|
||||
}, [ids]),
|
||||
useCallback(
|
||||
(state) => {
|
||||
return getSortedFeedSubscriptionByAlphabetSelector(state)(ids)
|
||||
},
|
||||
[ids],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const useSubscriptionById = (id: string | undefined | null) => {
|
||||
return useSubscriptionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
return id ? state.data[id] : undefined
|
||||
},
|
||||
[id],
|
||||
),
|
||||
)
|
||||
return useSubscriptionStore(useCallback((state) => getSubscriptionByIdSelector(state)(id), [id]))
|
||||
}
|
||||
export const useSubscriptionsByIds = (ids: string[]) => {
|
||||
const idsString = ids.toString()
|
||||
return useSubscriptionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
return ids.map((id) => state.data[id])
|
||||
},
|
||||
[ids.toString()],
|
||||
(state) => getSubscriptionsByIdsSelector(state)(ids),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[idsString],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
@ -278,72 +171,52 @@ export const useSubscriptionByListId = (listId: string | undefined | null) =>
|
|||
useSubscriptionById(listId)
|
||||
|
||||
export const useAllListSubscription = () => {
|
||||
return useSubscriptionStore(
|
||||
useCallback((state) => {
|
||||
return Object.values(state.listIdByView).flatMap((list) => Array.from(list))
|
||||
}, []),
|
||||
)
|
||||
return useSubscriptionStore((state) => getAllListSubscriptionSelector(state)())
|
||||
}
|
||||
|
||||
export const useListSubscription = (view: FeedViewType) => {
|
||||
return useSubscriptionStore(
|
||||
useCallback(
|
||||
(state) => Array.from(state.listIdByView[view]).map((listId) => state.data[listId]),
|
||||
[view],
|
||||
),
|
||||
useCallback((state) => getListSubscriptionSelector(state)(view), [view]),
|
||||
)
|
||||
}
|
||||
|
||||
export const useListSubscriptionIds = (view: FeedViewType) => {
|
||||
return useSubscriptionStore(useCallback((state) => Array.from(state.listIdByView[view]), [view]))
|
||||
return useSubscriptionStore(
|
||||
useCallback((state) => getListSubscriptionIdsSelector(state)(view), [view]),
|
||||
)
|
||||
}
|
||||
|
||||
export const useFeedSubscription = (view: FeedViewType) => {
|
||||
return useSubscriptionStore(
|
||||
useCallback(
|
||||
(state) => Array.from(state.feedIdByView[view]).map((feedId) => state.data[feedId]),
|
||||
[view],
|
||||
),
|
||||
useCallback((state) => getFeedSubscriptionSelector(state)(view), [view]),
|
||||
)
|
||||
}
|
||||
|
||||
export const useFeedSubscriptionIds = (view: FeedViewType) => {
|
||||
return useSubscriptionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
return Array.from(state.feedIdByView[view])
|
||||
},
|
||||
[view],
|
||||
),
|
||||
useCallback((state) => getFeedSubscriptionIdsSelector(state)(view), [view]),
|
||||
)
|
||||
}
|
||||
|
||||
export const useAllFeedSubscription = () => {
|
||||
return useSubscriptionStore(
|
||||
useCallback((state) => {
|
||||
return Object.values(state.feedIdByView).flatMap((feedId) =>
|
||||
Array.from(feedId)
|
||||
.map((id) => state.data[id])
|
||||
.filter((feed) => !!feed),
|
||||
)
|
||||
}, []),
|
||||
)
|
||||
const stableSelector = useRef((state: SubscriptionState) =>
|
||||
getAllFeedSubscriptionSelector(state)(),
|
||||
).current
|
||||
return useSubscriptionStore(stableSelector)
|
||||
}
|
||||
|
||||
export const useAllFeedSubscriptionIds = () => {
|
||||
return useSubscriptionStore(
|
||||
useCallback((state) => {
|
||||
return Object.values(state.feedIdByView).flatMap((feedId) => Array.from(feedId))
|
||||
}, []),
|
||||
)
|
||||
const stableSelector = useRef((state: SubscriptionState) =>
|
||||
getAllFeedSubscriptionIdsSelector(state)(),
|
||||
).current
|
||||
return useSubscriptionStore(stableSelector)
|
||||
}
|
||||
|
||||
export const useAllSubscription = () => {
|
||||
return useSubscriptionStore(
|
||||
useCallback((state) => {
|
||||
return Object.values(state.data).filter((subscription) => !!subscription)
|
||||
}, []),
|
||||
)
|
||||
const stableSelector = useRef((state: SubscriptionState) =>
|
||||
getAllSubscriptionSelector(state)(),
|
||||
).current
|
||||
return useSubscriptionStore(stableSelector)
|
||||
}
|
||||
|
||||
export const useSortedListSubscription = ({
|
||||
|
|
@ -356,47 +229,26 @@ export const useSortedListSubscription = ({
|
|||
hideAllReadSubscriptions: boolean
|
||||
}) => {
|
||||
return useSubscriptionStore(
|
||||
useCallback(() => {
|
||||
return ids
|
||||
.concat()
|
||||
.filter((id) => !hideAllReadSubscriptions || getUnreadByListId(id) > 0)
|
||||
.sort((a, b) => {
|
||||
const leftList = getListById(a)
|
||||
const rightList = getListById(b)
|
||||
if (!leftList || !rightList) return 0
|
||||
if (sortBy === "alphabet") {
|
||||
return sortByAlphabet(leftList.title || "", rightList.title || "")
|
||||
}
|
||||
return sortByUnread(a, b)
|
||||
useCallback(
|
||||
(state) => {
|
||||
return getSortedListSubscriptionSelector(state)({
|
||||
ids,
|
||||
sortBy,
|
||||
hideAllReadSubscriptions,
|
||||
})
|
||||
}, [ids.toString(), sortBy, hideAllReadSubscriptions]),
|
||||
},
|
||||
[ids, sortBy, hideAllReadSubscriptions],
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const useCategories = (view?: FeedViewType) => {
|
||||
return useSubscriptionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
return view === undefined
|
||||
? Array.from(
|
||||
new Set(Object.values(state.categories).flatMap((category) => Array.from(category))),
|
||||
)
|
||||
: Array.from(state.categories[view])
|
||||
},
|
||||
[view],
|
||||
),
|
||||
)
|
||||
return useSubscriptionStore(useCallback((state) => getCategoriesSelector(state)(view), [view]))
|
||||
}
|
||||
|
||||
export const useSubscriptionCategoryExist = (categoryId: string | undefined | null) => {
|
||||
return useSubscriptionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!categoryId) return false
|
||||
return Object.values(state.categories).some((category) => category.has(categoryId))
|
||||
},
|
||||
[categoryId],
|
||||
),
|
||||
useCallback((state) => getSubscriptionCategoryExistSelector(state)(categoryId), [categoryId]),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -406,73 +258,50 @@ export const getSubscriptionCategory = (view?: FeedViewType) => {
|
|||
}
|
||||
|
||||
export const useViewWithSubscription = () =>
|
||||
useSubscriptionStore(
|
||||
useCallback((state) => {
|
||||
return views
|
||||
.filter((view) => {
|
||||
if (
|
||||
view.view === FeedViewType.Articles ||
|
||||
view.view === FeedViewType.SocialMedia ||
|
||||
view.view === FeedViewType.Pictures ||
|
||||
view.view === FeedViewType.Videos
|
||||
) {
|
||||
return true
|
||||
} else {
|
||||
return state.feedIdByView[view.view].size > 0
|
||||
}
|
||||
})
|
||||
.map((v) => v.view)
|
||||
}, []),
|
||||
)
|
||||
useSubscriptionStore((state) => {
|
||||
return views
|
||||
.filter((view) => {
|
||||
if (
|
||||
view.view === FeedViewType.Articles ||
|
||||
view.view === FeedViewType.SocialMedia ||
|
||||
view.view === FeedViewType.Pictures ||
|
||||
view.view === FeedViewType.Videos
|
||||
) {
|
||||
return true
|
||||
} else {
|
||||
return state.feedIdByView[view.view].size > 0
|
||||
}
|
||||
})
|
||||
.map((v) => v.view)
|
||||
})
|
||||
|
||||
export const useCategoriesByView = (view: FeedViewType) => {
|
||||
return useSubscriptionStore(useCallback((state) => state.categories[view], [view]))
|
||||
return useSubscriptionStore(
|
||||
useCallback((state) => getCategoriesByViewSelector(state)(view), [view]),
|
||||
)
|
||||
}
|
||||
|
||||
export const useListSubscriptionCount = () => {
|
||||
return useSubscriptionStore(
|
||||
useCallback(
|
||||
(state) => Array.from(state.subscriptionIdSet).filter((id) => id.startsWith("list/")).length,
|
||||
[],
|
||||
),
|
||||
)
|
||||
const stableSelector = useRef((state: SubscriptionState) =>
|
||||
getListSubscriptionCountSelector(state)(),
|
||||
).current
|
||||
return useSubscriptionStore(stableSelector)
|
||||
}
|
||||
|
||||
export const useFeedSubscriptionCount = () => {
|
||||
return useSubscriptionStore(
|
||||
useCallback(
|
||||
(state) => Array.from(state.subscriptionIdSet).filter((id) => id.startsWith("feed/")).length,
|
||||
[],
|
||||
),
|
||||
)
|
||||
const stableSelector = useRef((state: SubscriptionState) =>
|
||||
getFeedSubscriptionCountSelector(state)(),
|
||||
).current
|
||||
return useSubscriptionStore(stableSelector)
|
||||
}
|
||||
|
||||
export const useIsSubscribed = (id: string | undefined) => {
|
||||
return useSubscriptionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!id) return false
|
||||
return (
|
||||
state.subscriptionIdSet.has(id) ||
|
||||
state.subscriptionIdSet.has(`feed/${id}`) ||
|
||||
state.subscriptionIdSet.has(`list/${id}`) ||
|
||||
state.subscriptionIdSet.has(`inbox/${id}`)
|
||||
)
|
||||
},
|
||||
[id],
|
||||
),
|
||||
)
|
||||
return useSubscriptionStore(useCallback((state) => getIsSubscribedSelector(state)(id), [id]))
|
||||
}
|
||||
|
||||
export const useIsListSubscription = (id: string | undefined) => {
|
||||
return useSubscriptionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
if (!id) return false
|
||||
return state.subscriptionIdSet.has(`list/${id}`)
|
||||
},
|
||||
[id],
|
||||
),
|
||||
useCallback((state) => getIsListSubscriptionSelector(state)(id), [id]),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -538,26 +367,18 @@ export const useSubscriptionListIds = (view: FeedViewType) => {
|
|||
|
||||
export const useCategoryOpenStateByView = (view: FeedViewType) => {
|
||||
return useSubscriptionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
return state.categoryOpenStateByView[view]
|
||||
},
|
||||
[view],
|
||||
),
|
||||
useCallback((state) => getCategoryOpenStateByViewSelector(state)(view), [view]),
|
||||
)
|
||||
}
|
||||
|
||||
export const useNonPrivateSubscriptionIds = (ids: string[]) => {
|
||||
const idsString = ids.toString()
|
||||
const nonPrivateSubscriptions = useSubscriptionStore(
|
||||
useCallback(
|
||||
(state) => {
|
||||
return ids
|
||||
.map((id) => state.data[id])
|
||||
.filter((s) => !s?.isPrivate)
|
||||
.map((s) => s?.listId || s?.feedId)
|
||||
.filter((id) => typeof id === "string")
|
||||
},
|
||||
[ids.toString()],
|
||||
(state) => getNonPrivateSubscriptionIdsSelector(state)(ids),
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[idsString],
|
||||
),
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue