feat: enhance scroll state management (#3962)
* feat: enhance scroll state management * Drop 古法手写代码,使用 agent 进行优化 * chore: minor update * update
This commit is contained in:
parent
b2570218d5
commit
e70c674ae6
|
|
@ -8,7 +8,7 @@ import { ELECTRON_BUILD } from "@follow/shared/constants"
|
|||
import { springScrollTo } from "@follow/utils/scroller"
|
||||
import { cn, getOS } from "@follow/utils/utils"
|
||||
import { m } from "framer-motion"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { NavigationType, Outlet, useLocation, useNavigate, useNavigationType } from "react-router"
|
||||
|
|
@ -34,11 +34,29 @@ function SubviewLayoutInner() {
|
|||
const [scrollY, setScrollY] = useState(0)
|
||||
const navigationType = useNavigationType()
|
||||
const location = useLocation()
|
||||
const [maxScroll, setMaxScroll] = useState(0)
|
||||
|
||||
// Enhanced scroll state management
|
||||
const isTitleVisible = scrollY > 60
|
||||
const isHeaderElevated = scrollY > 20
|
||||
|
||||
const updateMaxScroll = useCallback(() => {
|
||||
if (!scrollRef) return
|
||||
|
||||
const { scrollHeight, clientHeight } = scrollRef
|
||||
setMaxScroll(Math.max(0, scrollHeight - clientHeight))
|
||||
}, [scrollRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollRef) return
|
||||
|
||||
updateMaxScroll()
|
||||
const resizeObserver = new ResizeObserver(updateMaxScroll)
|
||||
resizeObserver.observe(scrollRef)
|
||||
|
||||
return () => resizeObserver.disconnect()
|
||||
}, [scrollRef, updateMaxScroll])
|
||||
|
||||
useEffect(() => {
|
||||
// Scroll to top search bar when re-navigating to Discover page while already on it
|
||||
if (
|
||||
|
|
@ -129,11 +147,11 @@ function SubviewLayoutInner() {
|
|||
|
||||
{/* Enhanced Title - absolutely centered */}
|
||||
<div className="pointer-events-none absolute inset-x-0 flex justify-center">
|
||||
{isTitleVisible && title && (
|
||||
{isTitleVisible && title ? (
|
||||
<div className="text-center">
|
||||
<div className="text-text truncate font-semibold">{title}</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Action Area - positioned on the right */}
|
||||
|
|
@ -155,12 +173,13 @@ function SubviewLayoutInner() {
|
|||
ref={setRef}
|
||||
rootClassName="w-full"
|
||||
viewportClassName="pb-12 pt-24 [&>div]:items-center"
|
||||
onUpdateMaxScroll={updateMaxScroll}
|
||||
>
|
||||
<Outlet />
|
||||
</ScrollArea.ScrollArea>
|
||||
|
||||
<RootPortal>
|
||||
<ScrollProgressFAB scrollY={scrollY} scrollRef={scrollRef} />
|
||||
<ScrollProgressFAB scrollY={scrollY} scrollRef={scrollRef} maxScroll={maxScroll} />
|
||||
</RootPortal>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -171,25 +190,15 @@ const SubViewHeaderRightView = () => {
|
|||
return <div className="inline-flex items-center">{rightView}</div>
|
||||
}
|
||||
|
||||
const ScrollProgressFAB = ({ scrollY, scrollRef }: { scrollY: number; scrollRef: any }) => {
|
||||
const [maxScroll, setMaxScroll] = useState(0)
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollRef) return
|
||||
|
||||
const updateMaxScroll = () => {
|
||||
const { scrollHeight, clientHeight } = scrollRef
|
||||
setMaxScroll(Math.max(0, scrollHeight - clientHeight))
|
||||
}
|
||||
|
||||
updateMaxScroll()
|
||||
const resizeObserver = new ResizeObserver(updateMaxScroll)
|
||||
resizeObserver.observe(scrollRef)
|
||||
|
||||
return () => resizeObserver.disconnect()
|
||||
}, [location.pathname, scrollRef])
|
||||
|
||||
const ScrollProgressFAB = ({
|
||||
scrollY,
|
||||
scrollRef,
|
||||
maxScroll,
|
||||
}: {
|
||||
scrollY: number
|
||||
scrollRef: any
|
||||
maxScroll: number
|
||||
}) => {
|
||||
const progress = maxScroll > 0 ? Math.min(100, (scrollY / maxScroll) * 100) : 0
|
||||
const showProgress = scrollY > 100 && maxScroll > 100
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import { useScrollElementUpdate } from "@follow/components/ui/scroll-area/hooks.js"
|
||||
import { ResponsiveSelect } from "@follow/components/ui/select/responsive.js"
|
||||
import { Skeleton } from "@follow/components/ui/skeleton/index.jsx"
|
||||
import { views } from "@follow/constants"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { useState } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { setUISetting, useUISettingKey } from "~/atoms/settings/ui"
|
||||
|
|
@ -53,6 +54,7 @@ export function Trending({
|
|||
const { t } = useTranslation()
|
||||
const { t: tCommon } = useTranslation("common")
|
||||
const lang = useUISettingKey("discoverLanguage")
|
||||
const { onUpdateMaxScroll } = useScrollElementUpdate()
|
||||
|
||||
const [selectedView, setSelectedView] = useState<View>("all")
|
||||
|
||||
|
|
@ -69,6 +71,12 @@ export function Trending({
|
|||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
onUpdateMaxScroll?.()
|
||||
}
|
||||
}, [isLoading])
|
||||
|
||||
return (
|
||||
<div className={cn("mx-auto mt-4 w-full max-w-[800px] space-y-6", narrow && "max-w-[400px]")}>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ import { EmptyIcon } from "@follow/components/icons/empty.js"
|
|||
import { Card } from "@follow/components/ui/card/index.jsx"
|
||||
import { Input } from "@follow/components/ui/input/Input.js"
|
||||
import { LoadingCircle } from "@follow/components/ui/loading/index.js"
|
||||
import { useScrollElementUpdate } from "@follow/components/ui/scroll-area/hooks.js"
|
||||
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/EllipsisWithTooltip.js"
|
||||
import { CategoryMap, RSSHubCategories } from "@follow/constants"
|
||||
import { cn, formatNumber } from "@follow/utils/utils"
|
||||
import { keepPreviousData } from "@tanstack/react-query"
|
||||
import { useMemo, useState } from "react"
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { Link, useParams } from "react-router"
|
||||
|
||||
|
|
@ -80,13 +81,17 @@ export const Component = () => {
|
|||
|
||||
const [search, setSearch] = useState("")
|
||||
|
||||
const items = keys.map((key) => {
|
||||
return {
|
||||
key,
|
||||
data: data![key],
|
||||
routePrefix: key,
|
||||
}
|
||||
})
|
||||
const items = useMemo(
|
||||
() =>
|
||||
keys.map((key) => {
|
||||
return {
|
||||
key,
|
||||
data: data![key],
|
||||
routePrefix: key,
|
||||
}
|
||||
}),
|
||||
[keys, data],
|
||||
)
|
||||
|
||||
const filteredItems = useMemo(() => {
|
||||
return items.filter((item) => {
|
||||
|
|
@ -104,6 +109,17 @@ export const Component = () => {
|
|||
})
|
||||
}, [items, search])
|
||||
|
||||
const { onUpdateMaxScroll } = useScrollElementUpdate()
|
||||
useEffect(() => {
|
||||
if (!isLoading && onUpdateMaxScroll) {
|
||||
// Defer to next tick to avoid blocking main thread
|
||||
const timeoutId = setTimeout(() => {
|
||||
onUpdateMaxScroll()
|
||||
}, 0)
|
||||
return () => clearTimeout(timeoutId)
|
||||
}
|
||||
}, [isLoading])
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[800px]">
|
||||
<div className="mb-10 flex w-full items-center justify-center gap-2 text-center text-2xl font-bold">
|
||||
|
|
@ -158,187 +174,218 @@ export const Component = () => {
|
|||
)
|
||||
}
|
||||
|
||||
const RecommendationListItem = ({
|
||||
data,
|
||||
routePrefix,
|
||||
rsshubAnalyticsData,
|
||||
}: {
|
||||
data: RouteData[string]
|
||||
routePrefix: string
|
||||
rsshubAnalyticsData: Awaited<
|
||||
ReturnType<(typeof apiClient)["discover"]["rsshub-analytics"]["$get"]>
|
||||
>["data"]
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { present } = useModalStack()
|
||||
const RecommendationListItem = memo(
|
||||
({
|
||||
data,
|
||||
routePrefix,
|
||||
rsshubAnalyticsData,
|
||||
}: {
|
||||
data: RouteData[string]
|
||||
routePrefix: string
|
||||
rsshubAnalyticsData: Awaited<
|
||||
ReturnType<(typeof apiClient)["discover"]["rsshub-analytics"]["$get"]>
|
||||
>["data"]
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { present } = useModalStack()
|
||||
|
||||
const { maintainers, categories, routes } = useMemo(() => {
|
||||
const maintainers = new Set<string>()
|
||||
const categories = new Set<string>()
|
||||
const routes = Object.keys(data.routes).sort((a, b) => {
|
||||
const aHeat = rsshubAnalyticsData?.[`/${routePrefix}${a}`]?.subscriptionCount ?? 0
|
||||
const bHeat = rsshubAnalyticsData?.[`/${routePrefix}${b}`]?.subscriptionCount ?? 0
|
||||
return bHeat - aHeat
|
||||
})
|
||||
const { maintainers, categories, routes } = useMemo(() => {
|
||||
const maintainers = new Set<string>()
|
||||
const categories = new Set<string>()
|
||||
const routes = Object.keys(data.routes).sort((a, b) => {
|
||||
const aHeat = rsshubAnalyticsData?.[`/${routePrefix}${a}`]?.subscriptionCount ?? 0
|
||||
const bHeat = rsshubAnalyticsData?.[`/${routePrefix}${b}`]?.subscriptionCount ?? 0
|
||||
return bHeat - aHeat
|
||||
})
|
||||
|
||||
for (const route in data.routes) {
|
||||
const routeData = data.routes[route]!
|
||||
if (routeData.maintainers) {
|
||||
routeData.maintainers.forEach((m) => maintainers.add(m))
|
||||
for (const route in data.routes) {
|
||||
const routeData = data.routes[route]!
|
||||
if (routeData.maintainers) {
|
||||
routeData.maintainers.forEach((m) => maintainers.add(m))
|
||||
}
|
||||
if (routeData.categories) {
|
||||
routeData.categories.forEach((c) => categories.add(c))
|
||||
}
|
||||
}
|
||||
if (routeData.categories) {
|
||||
routeData.categories.forEach((c) => categories.add(c))
|
||||
categories.delete("popular")
|
||||
return {
|
||||
maintainers: Array.from(maintainers),
|
||||
categories: Array.from(categories) as unknown as typeof RSSHubCategories,
|
||||
routes,
|
||||
}
|
||||
}
|
||||
categories.delete("popular")
|
||||
return {
|
||||
maintainers: Array.from(maintainers),
|
||||
categories: Array.from(categories) as unknown as typeof RSSHubCategories,
|
||||
routes,
|
||||
}
|
||||
}, [data, rsshubAnalyticsData, routePrefix])
|
||||
}, [data, rsshubAnalyticsData, routePrefix])
|
||||
|
||||
const follow = useFollow()
|
||||
const follow = useFollow()
|
||||
|
||||
return (
|
||||
<Card className="shadow-background border-border overflow-hidden rounded-lg border transition-shadow duration-200 hover:shadow-md">
|
||||
<div className="border-border flex items-center gap-3 border-b p-4">
|
||||
<div className="bg-background size-8 overflow-hidden rounded-full">
|
||||
<FeedIcon className="mr-0 size-8" size={32} siteUrl={`https://${data.url}`} />
|
||||
</div>
|
||||
<div className="flex w-full flex-1 justify-between">
|
||||
<h3 className="line-clamp-1 text-base font-medium">
|
||||
<a
|
||||
href={`https://${data.url}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
{data.name}
|
||||
</a>
|
||||
</h3>
|
||||
const handleRouteClick = useCallback(
|
||||
(route: string) => {
|
||||
present({
|
||||
id: `recommendation-content-${route}`,
|
||||
content: () => (
|
||||
<RecommendationContent routePrefix={routePrefix} route={data.routes[route]!} />
|
||||
),
|
||||
icon: <FeedIcon className="size-4" size={16} siteUrl={`https://${data.url}`} />,
|
||||
title: `${data.name} - ${data.routes[route]!.name}`,
|
||||
})
|
||||
},
|
||||
[present, routePrefix, data, data.url, data.name],
|
||||
)
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 text-xs">
|
||||
{categories.map((c) => (
|
||||
<Link
|
||||
to={`/discover/category/${c}`}
|
||||
key={c}
|
||||
className={cn(
|
||||
"bg-accent/10 cursor-pointer rounded-full px-2 py-0.5 leading-5 duration-200",
|
||||
!RSSHubCategories.includes(c) ? "pointer-events-none opacity-50" : "",
|
||||
)}
|
||||
>
|
||||
{RSSHubCategories.includes(c)
|
||||
? t(`discover.category.${c}`, { ns: "common" })
|
||||
: c.charAt(0).toUpperCase() + c.slice(1)}
|
||||
</Link>
|
||||
))}
|
||||
const handleFeedClick = useCallback(
|
||||
(feedId: string) => {
|
||||
follow({
|
||||
isList: false,
|
||||
id: feedId,
|
||||
})
|
||||
},
|
||||
[follow],
|
||||
)
|
||||
|
||||
return (
|
||||
<Card className="shadow-background border-border overflow-hidden rounded-lg border transition-shadow duration-200 hover:shadow-md">
|
||||
<div className="border-border flex items-center gap-3 border-b p-4">
|
||||
<div className="bg-background size-8 overflow-hidden rounded-full">
|
||||
<FeedIcon className="mr-0 size-8" size={32} siteUrl={`https://${data.url}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 pt-2">
|
||||
<ul className="text-text mb-3">
|
||||
{routes.map((route) => {
|
||||
const routeData = data.routes[route]!
|
||||
if (Array.isArray(routeData.path)) {
|
||||
routeData.path = routeData.path.find((p) => p === route) ?? routeData.path[0]
|
||||
}
|
||||
|
||||
const analytics = rsshubAnalyticsData?.[`/${routePrefix}${routeData.path}`]
|
||||
|
||||
return (
|
||||
<li
|
||||
key={route}
|
||||
className="hover:bg-material-opaque -mx-4 rounded p-3 px-5 transition-colors"
|
||||
role="button"
|
||||
onClick={() => {
|
||||
present({
|
||||
id: `recommendation-content-${route}`,
|
||||
content: () => (
|
||||
<RecommendationContent
|
||||
routePrefix={routePrefix}
|
||||
route={data.routes[route]!}
|
||||
/>
|
||||
),
|
||||
icon: <FeedIcon className="size-4" size={16} siteUrl={`https://${data.url}`} />,
|
||||
title: `${data.name} - ${data.routes[route]!.name}`,
|
||||
})
|
||||
}}
|
||||
<div className="flex w-full flex-1 justify-between">
|
||||
<h3 className="line-clamp-1 text-base font-medium">
|
||||
<a
|
||||
href={`https://${data.url}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="hover:underline"
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex w-full items-center gap-8">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="bg-accent mr-2 size-1.5 rounded-full" />
|
||||
<div className="relative h-5 grow">
|
||||
<div className="text-title3 absolute inset-0 flex items-center gap-3 font-medium">
|
||||
<EllipsisHorizontalTextWithTooltip>
|
||||
{routeData.name}
|
||||
</EllipsisHorizontalTextWithTooltip>
|
||||
<EllipsisHorizontalTextWithTooltip className="text-text-secondary text-xs">{`rsshub://${routePrefix}${routeData.path}`}</EllipsisHorizontalTextWithTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!!analytics?.subscriptionCount && (
|
||||
<div className="flex items-center gap-0.5 text-xs">
|
||||
<i className="i-mgc-fire-cute-re" />
|
||||
{formatNumber(analytics?.subscriptionCount || 0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{analytics?.topFeeds && (
|
||||
<div className="mt-2 flex items-center gap-10 pl-5 text-xs">
|
||||
{analytics.topFeeds.slice(0, 2).map((feed) => (
|
||||
<div key={feed.id} className="flex w-2/5 flex-1 items-center text-sm">
|
||||
<FeedIcon
|
||||
feed={feed}
|
||||
className="mask-squircle mask shrink-0 rounded-none"
|
||||
size={16}
|
||||
/>
|
||||
<div
|
||||
className="min-w-0 leading-tight"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
follow({
|
||||
isList: false,
|
||||
id: feed.id,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<EllipsisHorizontalTextWithTooltip className="truncate">
|
||||
{getPreferredTitle(feed) || feed?.title}
|
||||
</EllipsisHorizontalTextWithTooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{data.name}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-wrap gap-1.5 text-xs">
|
||||
{categories.map((c) => (
|
||||
<Link
|
||||
to={`/discover/category/${c}`}
|
||||
key={c}
|
||||
className={cn(
|
||||
"bg-accent/10 cursor-pointer rounded-full px-2 py-0.5 leading-5 duration-200",
|
||||
!RSSHubCategories.includes(c) ? "pointer-events-none opacity-50" : "",
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{maintainers.length > 0 && (
|
||||
<div className="text-text-secondary mt-2 flex items-center text-xs">
|
||||
<i className="i-mgc-hammer-cute-re mr-1 shrink-0 translate-y-0.5 self-start" />
|
||||
<span>
|
||||
{maintainers.map((m, i) => (
|
||||
<span key={m}>
|
||||
<a
|
||||
href={`https://github.com/${m}`}
|
||||
className="hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
@{m}
|
||||
</a>
|
||||
{i < maintainers.length - 1 ? ", " : ""}
|
||||
</span>
|
||||
>
|
||||
{RSSHubCategories.includes(c)
|
||||
? t(`discover.category.${c}`, { ns: "common" })
|
||||
: c.charAt(0).toUpperCase() + c.slice(1)}
|
||||
</Link>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className="p-4 pt-2">
|
||||
<ul className="text-text mb-3">
|
||||
{routes.map((route) => (
|
||||
<RouteItem
|
||||
key={route}
|
||||
route={route}
|
||||
routeData={data.routes[route]!}
|
||||
routePrefix={routePrefix}
|
||||
rsshubAnalyticsData={rsshubAnalyticsData}
|
||||
onRouteClick={handleRouteClick}
|
||||
onFeedClick={handleFeedClick}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
{maintainers.length > 0 && (
|
||||
<div className="text-text-secondary mt-2 flex items-center text-xs">
|
||||
<i className="i-mgc-hammer-cute-re mr-1 shrink-0 translate-y-0.5 self-start" />
|
||||
<span>
|
||||
{maintainers.map((m, i) => (
|
||||
<span key={m}>
|
||||
<a
|
||||
href={`https://github.com/${m}`}
|
||||
className="hover:underline"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
@{m}
|
||||
</a>
|
||||
{i < maintainers.length - 1 ? ", " : ""}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const RouteItem = memo(
|
||||
({
|
||||
route,
|
||||
routeData,
|
||||
routePrefix,
|
||||
rsshubAnalyticsData,
|
||||
onRouteClick,
|
||||
onFeedClick,
|
||||
}: {
|
||||
route: string
|
||||
routeData: any
|
||||
routePrefix: string
|
||||
rsshubAnalyticsData: any
|
||||
onRouteClick: (route: string) => void
|
||||
onFeedClick: (feedId: string) => void
|
||||
}) => {
|
||||
if (Array.isArray(routeData.path)) {
|
||||
routeData.path = routeData.path.find((p: string) => p === route) ?? routeData.path[0]
|
||||
}
|
||||
|
||||
const analytics = rsshubAnalyticsData?.[`/${routePrefix}${routeData.path}`]
|
||||
|
||||
return (
|
||||
<li
|
||||
className="hover:bg-material-opaque -mx-4 rounded p-3 px-5 transition-colors"
|
||||
role="button"
|
||||
onClick={() => onRouteClick(route)}
|
||||
>
|
||||
<div className="w-full">
|
||||
<div className="flex w-full items-center gap-8">
|
||||
<div className="flex flex-1 items-center gap-2">
|
||||
<div className="bg-accent mr-2 size-1.5 rounded-full" />
|
||||
<div className="relative h-5 grow">
|
||||
<div className="text-title3 absolute inset-0 flex items-center gap-3 font-medium">
|
||||
<EllipsisHorizontalTextWithTooltip>
|
||||
{routeData.name}
|
||||
</EllipsisHorizontalTextWithTooltip>
|
||||
<EllipsisHorizontalTextWithTooltip className="text-text-secondary text-xs">{`rsshub://${routePrefix}${routeData.path}`}</EllipsisHorizontalTextWithTooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{!!analytics?.subscriptionCount && (
|
||||
<div className="flex items-center gap-0.5 text-xs">
|
||||
<i className="i-mgc-fire-cute-re" />
|
||||
{formatNumber(analytics?.subscriptionCount || 0)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{analytics?.topFeeds && (
|
||||
<div className="mt-2 flex items-center gap-10 pl-5 text-xs">
|
||||
{analytics.topFeeds.slice(0, 2).map((feed: any) => (
|
||||
<div key={feed.id} className="flex w-2/5 flex-1 items-center text-sm">
|
||||
<FeedIcon
|
||||
feed={feed}
|
||||
className="mask-squircle mask shrink-0 rounded-none"
|
||||
size={16}
|
||||
/>
|
||||
<div className="min-w-0 leading-tight" onClick={() => onFeedClick(feed.id)}>
|
||||
<EllipsisHorizontalTextWithTooltip className="truncate">
|
||||
{getPreferredTitle(feed) || feed?.title}
|
||||
</EllipsisHorizontalTextWithTooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { Divider } from "@follow/components/ui/divider/Divider.js"
|
||||
import { useScrollElementUpdate } from "@follow/components/ui/scroll-area/hooks.js"
|
||||
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@follow/components/ui/tabs/index.jsx"
|
||||
import { UserRole } from "@follow/constants"
|
||||
|
|
@ -63,6 +64,7 @@ export function Component() {
|
|||
|
||||
const presentActivationModal = useActivationModal()
|
||||
const role = useUserRole()
|
||||
const { onUpdateMaxScroll } = useScrollElementUpdate()
|
||||
|
||||
const currentTabs = tabs.map((tab) => {
|
||||
const disabled = tab.disableForTrial && role === UserRole.Trial
|
||||
|
|
@ -105,6 +107,8 @@ export function Component() {
|
|||
onClick={() => {
|
||||
if (tab.disabled) {
|
||||
presentActivationModal()
|
||||
} else {
|
||||
onUpdateMaxScroll?.()
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { cn } from "@follow/utils/utils"
|
|||
import * as ScrollAreaBase from "@radix-ui/react-scroll-area"
|
||||
import * as React from "react"
|
||||
|
||||
import { ScrollElementContext } from "./ctx"
|
||||
import { ScrollElementContext, ScrollElementEventsContext } from "./ctx"
|
||||
import styles from "./index.module.css"
|
||||
|
||||
const Corner = ({
|
||||
|
|
@ -144,7 +144,7 @@ export const ScrollArea = ({
|
|||
onScroll,
|
||||
orientation = "vertical",
|
||||
asChild = false,
|
||||
|
||||
onUpdateMaxScroll,
|
||||
focusable = true,
|
||||
}: React.PropsWithChildren & {
|
||||
rootClassName?: string
|
||||
|
|
@ -153,6 +153,7 @@ export const ScrollArea = ({
|
|||
flex?: boolean
|
||||
mask?: boolean
|
||||
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void
|
||||
onUpdateMaxScroll?: () => void
|
||||
orientation?: "vertical" | "horizontal"
|
||||
asChild?: boolean
|
||||
focusable?: boolean
|
||||
|
|
@ -160,22 +161,26 @@ export const ScrollArea = ({
|
|||
const [viewportRef, setViewportRef] = React.useState<HTMLDivElement | null>(null)
|
||||
React.useImperativeHandle(ref, () => viewportRef as HTMLDivElement)
|
||||
|
||||
const events = React.useMemo(() => ({ onUpdateMaxScroll }), [onUpdateMaxScroll])
|
||||
|
||||
return (
|
||||
<ScrollElementContext value={viewportRef}>
|
||||
<Root className={rootClassName}>
|
||||
<Viewport
|
||||
ref={setViewportRef}
|
||||
onWheel={stopPropagation}
|
||||
className={cn(flex ? "[&>div]:!flex [&>div]:!flex-col" : "", viewportClassName)}
|
||||
mask={mask}
|
||||
asChild={asChild}
|
||||
onScroll={onScroll}
|
||||
focusable={focusable}
|
||||
>
|
||||
{children}
|
||||
</Viewport>
|
||||
<Scrollbar orientation={orientation} className={scrollbarClassName} />
|
||||
</Root>
|
||||
<ScrollElementEventsContext value={events}>
|
||||
<Root className={rootClassName}>
|
||||
<Viewport
|
||||
ref={setViewportRef}
|
||||
onWheel={stopPropagation}
|
||||
className={cn(flex ? "[&>div]:!flex [&>div]:!flex-col" : "", viewportClassName)}
|
||||
mask={mask}
|
||||
asChild={asChild}
|
||||
onScroll={onScroll}
|
||||
focusable={focusable}
|
||||
>
|
||||
{children}
|
||||
</Viewport>
|
||||
<Scrollbar orientation={orientation} className={scrollbarClassName} />
|
||||
</Root>
|
||||
</ScrollElementEventsContext>
|
||||
</ScrollElementContext>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,9 @@
|
|||
import { createContext } from "react"
|
||||
|
||||
export const ScrollElementContext = createContext<HTMLElement | null>(document.documentElement)
|
||||
|
||||
export const ScrollElementEventsContext = createContext<{
|
||||
onUpdateMaxScroll?: () => void
|
||||
}>({
|
||||
onUpdateMaxScroll: undefined,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useIsomorphicLayoutEffect } from "foxact/use-isomorphic-layout-effect"
|
|||
import { use, useCallback, useRef, useState } from "react"
|
||||
import { useEventCallback } from "usehooks-ts"
|
||||
|
||||
import { ScrollElementContext } from "./ctx"
|
||||
import { ScrollElementContext, ScrollElementEventsContext } from "./ctx"
|
||||
|
||||
const THRESHOLD = 0
|
||||
export const useMaskScrollArea = <T extends HTMLElement = HTMLElement>({
|
||||
|
|
@ -101,3 +101,5 @@ export const useMaskScrollArea = <T extends HTMLElement = HTMLElement>({
|
|||
* @returns
|
||||
*/
|
||||
export const useScrollViewElement = () => use(ScrollElementContext)
|
||||
|
||||
export const useScrollElementUpdate = () => use(ScrollElementEventsContext)
|
||||
|
|
|
|||
Loading…
Reference in New Issue