465 lines
15 KiB
TypeScript
465 lines
15 KiB
TypeScript
import { useDraggable } from "@dnd-kit/core"
|
|
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
|
|
import type { FeedViewType } from "@follow/constants"
|
|
import { views } from "@follow/constants"
|
|
import { stopPropagation } from "@follow/utils/dom"
|
|
import { cn } from "@follow/utils/utils"
|
|
import * as HoverCard from "@radix-ui/react-hover-card"
|
|
import { AnimatePresence, m } from "framer-motion"
|
|
import { memo, useMemo, useRef, useState } from "react"
|
|
import { useTranslation } from "react-i18next"
|
|
import { Link } from "react-router-dom"
|
|
import Selecto from "react-selecto"
|
|
import { useEventListener } from "usehooks-ts"
|
|
|
|
import { IconOpacityTransition } from "~/components/ux/transition/icon"
|
|
import { FEED_COLLECTION_LIST } from "~/constants"
|
|
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
|
import { useRouteFeedId } from "~/hooks/biz/useRouteParams"
|
|
import { useAuthQuery } from "~/hooks/common"
|
|
import { Queries } from "~/queries"
|
|
import {
|
|
subscriptionActions,
|
|
useCategoryOpenStateByView,
|
|
useSubscriptionByView,
|
|
} from "~/store/subscription"
|
|
import { useFeedUnreadStore } from "~/store/unread"
|
|
|
|
import {
|
|
getFeedListSort,
|
|
setFeedAreaScrollProgressValue,
|
|
setFeedListSortBy,
|
|
setFeedListSortOrder,
|
|
useFeedListSort,
|
|
useSelectedFeedIds,
|
|
} from "./atom"
|
|
import { DraggableContext } from "./context"
|
|
import { useShouldFreeUpSpace } from "./hook"
|
|
import { SortableFeedList, SortByAlphabeticalInbox, SortByAlphabeticalList } from "./sort-by"
|
|
import { feedColumnStyles } from "./styles"
|
|
import { UnreadNumber } from "./unread-number"
|
|
|
|
const useFeedsGroupedData = (view: FeedViewType) => {
|
|
const { data: remoteData } = useAuthQuery(Queries.subscription.byView(view))
|
|
|
|
const data = useSubscriptionByView(view) || remoteData
|
|
|
|
return useMemo(() => {
|
|
if (!data || data.length === 0) return {}
|
|
|
|
const groupFolder = {} as Record<string, string[]>
|
|
|
|
for (const subscription of data) {
|
|
const category = subscription.category || subscription.defaultCategory
|
|
if (category) {
|
|
if (!groupFolder[category]) {
|
|
groupFolder[category] = []
|
|
}
|
|
groupFolder[category].push(subscription.feedId)
|
|
}
|
|
}
|
|
|
|
return groupFolder
|
|
}, [data])
|
|
}
|
|
|
|
const useListsGroupedData = (view: FeedViewType) => {
|
|
const { data: remoteData } = useAuthQuery(Queries.subscription.byView(view))
|
|
|
|
const data = useSubscriptionByView(view) || remoteData
|
|
|
|
return useMemo(() => {
|
|
if (!data || data.length === 0) return {}
|
|
|
|
const lists = data.filter((s) => "listId" in s)
|
|
|
|
const groupFolder = {} as Record<string, string[]>
|
|
|
|
for (const subscription of lists) {
|
|
groupFolder[subscription.feedId] = [subscription.feedId]
|
|
}
|
|
|
|
return groupFolder
|
|
}, [data])
|
|
}
|
|
|
|
const useInboxesGroupedData = (view: FeedViewType) => {
|
|
const { data: remoteData } = useAuthQuery(Queries.subscription.byView(view))
|
|
|
|
const data = useSubscriptionByView(view) || remoteData
|
|
|
|
return useMemo(() => {
|
|
if (!data || data.length === 0) return {}
|
|
|
|
const inboxes = data.filter((s) => "inboxId" in s)
|
|
|
|
const groupFolder = {} as Record<string, string[]>
|
|
|
|
for (const subscription of inboxes) {
|
|
if (!subscription.inboxId) continue
|
|
groupFolder[subscription.inboxId] = [subscription.inboxId]
|
|
}
|
|
|
|
return groupFolder
|
|
}, [data])
|
|
}
|
|
|
|
const useUpdateUnreadCount = () => {
|
|
useAuthQuery(Queries.subscription.unreadAll(), {
|
|
refetchInterval: false,
|
|
})
|
|
}
|
|
|
|
function FeedListImpl({ className, view }: { className?: string; view: number }) {
|
|
const feedsData = useFeedsGroupedData(view)
|
|
const listsData = useListsGroupedData(view)
|
|
const inboxesData = useInboxesGroupedData(view)
|
|
const categoryOpenStateData = useCategoryOpenStateByView(view)
|
|
|
|
const hasData =
|
|
Object.keys(feedsData).length > 0 ||
|
|
Object.keys(listsData).length > 0 ||
|
|
Object.keys(inboxesData).length > 0
|
|
|
|
const feedId = useRouteFeedId()
|
|
const navigateEntry = useNavigateEntry()
|
|
|
|
const { t } = useTranslation()
|
|
|
|
// Data prefetch
|
|
useAuthQuery(Queries.lists.list())
|
|
|
|
const hasListData = Object.keys(listsData).length > 0
|
|
const hasInboxData = Object.keys(inboxesData).length > 0
|
|
|
|
const scrollerRef = useRef<HTMLDivElement>(null)
|
|
const selectoRef = useRef<Selecto>(null)
|
|
const [selectedFeedIds, setSelectedFeedIds] = useSelectedFeedIds()
|
|
|
|
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
|
id: "selected-feed",
|
|
disabled: selectedFeedIds.length === 0,
|
|
})
|
|
const style = useMemo(
|
|
() =>
|
|
transform
|
|
? ({
|
|
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
|
transitionDuration: "0",
|
|
transition: "none",
|
|
} as React.CSSProperties)
|
|
: undefined,
|
|
[transform],
|
|
)
|
|
|
|
const draggableContextValue = useMemo(
|
|
() => ({
|
|
attributes,
|
|
listeners,
|
|
style: {
|
|
...style,
|
|
willChange: "transform",
|
|
},
|
|
}),
|
|
[attributes, listeners, style],
|
|
)
|
|
|
|
useEventListener(
|
|
"scroll",
|
|
() => {
|
|
const round = (num: number) => Math.round(num * 1e2) / 1e2
|
|
const getPositions = () => {
|
|
const el = scrollerRef.current
|
|
if (!el) return
|
|
|
|
return {
|
|
x: round(el.scrollLeft / (el.scrollWidth - el.clientWidth)),
|
|
y: round(el.scrollTop / (el.scrollHeight - el.clientHeight)),
|
|
}
|
|
}
|
|
|
|
const newScrollValues = getPositions()
|
|
if (!newScrollValues) return
|
|
|
|
const { y } = newScrollValues
|
|
setFeedAreaScrollProgressValue(y)
|
|
},
|
|
scrollerRef,
|
|
{ capture: false, passive: true },
|
|
)
|
|
|
|
const shouldFreeUpSpace = useShouldFreeUpSpace()
|
|
|
|
return (
|
|
<div className={cn(className, "font-medium")}>
|
|
<ListHeader view={view} />
|
|
<Selecto
|
|
className="!border-theme-accent-400 !bg-theme-accent-400/60"
|
|
ref={selectoRef}
|
|
rootContainer={document.body}
|
|
dragContainer={"#feeds-area"}
|
|
dragCondition={() => !(selectedFeedIds.length > 0 && isDragging)}
|
|
selectableTargets={["[data-feed-id]"]}
|
|
continueSelect
|
|
hitRate={10}
|
|
onSelect={(e) => {
|
|
const allChanged = [...e.added, ...e.removed]
|
|
|
|
setSelectedFeedIds((prev) => {
|
|
const added = allChanged
|
|
.map((el) => el.dataset.feedId)
|
|
.filter((id) => id !== undefined)
|
|
.filter((id) => !prev.includes(id))
|
|
const removed = new Set(
|
|
allChanged
|
|
.map((el) => el.dataset.feedId)
|
|
.filter((id) => id !== undefined)
|
|
.filter((id) => prev.includes(id)),
|
|
)
|
|
return [...prev.filter((id) => !removed.has(id)), ...added]
|
|
})
|
|
}}
|
|
scrollOptions={{
|
|
container: scrollerRef.current as HTMLElement,
|
|
throttleTime: 30,
|
|
threshold: 0,
|
|
}}
|
|
onScroll={(e) => {
|
|
scrollerRef.current?.scrollBy(e.direction[0] * 10, e.direction[1] * 10)
|
|
}}
|
|
/>
|
|
|
|
<ScrollArea.ScrollArea
|
|
ref={scrollerRef}
|
|
onScroll={() => {
|
|
selectoRef.current?.checkScroll()
|
|
}}
|
|
mask={false}
|
|
flex
|
|
viewportClassName={cn("!px-3", shouldFreeUpSpace && "!overflow-visible")}
|
|
rootClassName={cn("h-full", shouldFreeUpSpace && "overflow-visible")}
|
|
>
|
|
<div
|
|
data-active={feedId === FEED_COLLECTION_LIST}
|
|
className={cn(
|
|
"mt-1 flex h-8 w-full shrink-0 cursor-menu items-center gap-2 rounded-md px-2.5",
|
|
feedColumnStyles.item,
|
|
)}
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
if (view !== undefined) {
|
|
navigateEntry({
|
|
entryId: null,
|
|
feedId: FEED_COLLECTION_LIST,
|
|
view,
|
|
})
|
|
}
|
|
}}
|
|
>
|
|
<i className="i-mgc-star-cute-fi size-4 -translate-y-px text-amber-500" />
|
|
{t("words.starred")}
|
|
</div>
|
|
{hasListData && (
|
|
<>
|
|
<div className="mt-1 flex h-6 w-full shrink-0 items-center rounded-md px-2.5 text-xs font-semibold text-theme-vibrancyFg transition-colors">
|
|
{t("words.lists")}
|
|
</div>
|
|
<SortByAlphabeticalList view={view} data={listsData} />
|
|
</>
|
|
)}
|
|
{hasInboxData && (
|
|
<>
|
|
<div className="mt-1 flex h-6 w-full shrink-0 items-center rounded-md px-2.5 text-xs font-semibold text-theme-vibrancyFg transition-colors">
|
|
{t("words.inbox")}
|
|
</div>
|
|
<SortByAlphabeticalInbox view={view} data={inboxesData} />
|
|
</>
|
|
)}
|
|
|
|
<DraggableContext.Provider value={draggableContextValue}>
|
|
<div className="space-y-px" id="feeds-area" ref={setNodeRef}>
|
|
{(hasListData || hasInboxData) && (
|
|
<div
|
|
className={cn(
|
|
"mb-1 flex h-6 w-full shrink-0 items-center rounded-md px-2.5 text-xs font-semibold text-theme-vibrancyFg transition-colors",
|
|
Object.keys(feedsData).length === 0 ? "mt-0" : "mt-1",
|
|
)}
|
|
>
|
|
{t("words.feeds")}
|
|
</div>
|
|
)}
|
|
{hasData ? (
|
|
<SortableFeedList
|
|
view={view}
|
|
data={feedsData}
|
|
categoryOpenStateData={categoryOpenStateData}
|
|
/>
|
|
) : (
|
|
<div className="flex h-full flex-1 items-center font-normal text-zinc-500">
|
|
<Link
|
|
to="/discover"
|
|
className="absolute inset-0 mt-[-3.75rem] flex h-full flex-1 cursor-menu flex-col items-center justify-center gap-2"
|
|
onClick={stopPropagation}
|
|
>
|
|
<i className="i-mgc-add-cute-re text-3xl" />
|
|
<span className="text-base">{t("sidebar.add_more_feeds")}</span>
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</DraggableContext.Provider>
|
|
</ScrollArea.ScrollArea>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const ListHeader = ({ view }: { view: number }) => {
|
|
const { t } = useTranslation()
|
|
const feedsData = useFeedsGroupedData(view)
|
|
const categoryOpenStateData = useCategoryOpenStateByView(view)
|
|
const expansion = Object.values(categoryOpenStateData).every((value) => value === true)
|
|
useUpdateUnreadCount()
|
|
|
|
const totalUnread = useFeedUnreadStore((state) => {
|
|
let unread = 0
|
|
|
|
for (const category in feedsData) {
|
|
for (const feedId of feedsData[category]) {
|
|
unread += state.data[feedId] || 0
|
|
}
|
|
}
|
|
return unread
|
|
})
|
|
|
|
const navigateEntry = useNavigateEntry()
|
|
|
|
return (
|
|
<div onClick={stopPropagation} className="mx-3 flex items-center justify-between px-2.5 py-1">
|
|
<div
|
|
className="font-bold"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
if (!document.hasFocus()) return
|
|
if (view !== undefined) {
|
|
navigateEntry({
|
|
entryId: null,
|
|
feedId: null,
|
|
view,
|
|
})
|
|
}
|
|
}}
|
|
>
|
|
{view !== undefined && t(views[view].name)}
|
|
</div>
|
|
<div className="ml-2 flex items-center gap-3 text-sm text-theme-vibrancyFg">
|
|
<SortButton />
|
|
{expansion ? (
|
|
<i
|
|
className="i-mgc-list-collapse-cute-re"
|
|
onClick={() => subscriptionActions.expandCategoryOpenStateByView(view, false)}
|
|
/>
|
|
) : (
|
|
<i
|
|
className="i-mgc-list-expansion-cute-re"
|
|
onClick={() => subscriptionActions.expandCategoryOpenStateByView(view, true)}
|
|
/>
|
|
)}
|
|
<UnreadNumber unread={totalUnread} className="text-xs !text-inherit" />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const SORT_LIST = [
|
|
{ icon: "i-mgc-sort-ascending-cute-re", by: "count", order: "asc" },
|
|
{ icon: "i-mgc-sort-descending-cute-re", by: "count", order: "desc" },
|
|
|
|
{
|
|
icon: "i-mgc-az-sort-descending-letters-cute-re",
|
|
by: "alphabetical",
|
|
order: "asc",
|
|
},
|
|
{
|
|
icon: "i-mgc-az-sort-ascending-letters-cute-re",
|
|
by: "alphabetical",
|
|
order: "desc",
|
|
},
|
|
] as const
|
|
|
|
const SortButton = () => {
|
|
const { by, order } = useFeedListSort()
|
|
const { t } = useTranslation()
|
|
|
|
const [open, setOpen] = useState(false)
|
|
|
|
return (
|
|
<HoverCard.Root open={open} onOpenChange={setOpen}>
|
|
<HoverCard.Trigger
|
|
onClick={() => {
|
|
setFeedListSortBy(by === "count" ? "alphabetical" : "count")
|
|
}}
|
|
className="center"
|
|
>
|
|
<IconOpacityTransition
|
|
icon2={
|
|
order === "asc"
|
|
? tw`i-mgc-az-sort-descending-letters-cute-re`
|
|
: tw`i-mgc-az-sort-ascending-letters-cute-re`
|
|
}
|
|
icon1={
|
|
order === "asc" ? tw`i-mgc-sort-ascending-cute-re` : tw`i-mgc-sort-descending-cute-re`
|
|
}
|
|
status={by === "count" ? "init" : "done"}
|
|
/>
|
|
</HoverCard.Trigger>
|
|
|
|
<HoverCard.Portal forceMount>
|
|
<HoverCard.Content className="z-10 -translate-x-4" sideOffset={5} forceMount>
|
|
<AnimatePresence>
|
|
{open && (
|
|
<m.div
|
|
initial={{ opacity: 0, scale: 0.98, y: 10 }}
|
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
exit={{ opacity: 0, scale: 0.98, y: 10 }}
|
|
transition={{ type: "spring", duration: 0.3 }}
|
|
className="relative z-10 rounded-md border border-border bg-theme-modal-background-opaque p-3 shadow-md dark:shadow-zinc-500/20"
|
|
>
|
|
<HoverCard.Arrow className="-translate-x-4 fill-border" />
|
|
<section className="w-[170px] text-center">
|
|
<span className="text-[13px]">{t("sidebar.select_sort_method")}</span>
|
|
<div className="mt-4 grid grid-cols-2 grid-rows-2 gap-2">
|
|
{SORT_LIST.map(({ icon, by, order }) => {
|
|
const current = getFeedListSort()
|
|
const active = by === current.by && order === current.order
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setFeedListSortBy(by)
|
|
setFeedListSortOrder(order)
|
|
}}
|
|
key={`${by}-${order}`}
|
|
className={cn(
|
|
"center flex aspect-square rounded border border-border",
|
|
|
|
"ring-0 ring-accent/20 duration-200",
|
|
active && "border-accent bg-accent/5 ring-2",
|
|
)}
|
|
>
|
|
<i className={`${icon} size-5`} />
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</section>
|
|
</m.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</HoverCard.Content>
|
|
</HoverCard.Portal>
|
|
</HoverCard.Root>
|
|
)
|
|
}
|
|
|
|
export const FeedList = memo(FeedListImpl)
|