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:
Innei 2025-08-13 23:52:33 +08:00
parent b55753918d
commit db7b3ede20
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
13 changed files with 845 additions and 431 deletions

View File

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

View File

@ -9,6 +9,10 @@ export const [, , useEntryTitleMeta, , getEntryTitleMeta, setEntryTitleMeta] = c
null as Nullable<{
entryTitle: string
feedTitle: string
// id-set
feedId: string
entryId: string
}>,
),
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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