feat(ui): enhance debug feature management and chat ai entry content layout
- Updated the debug feature atom to support feature overrides and added a new hook for setting debug feature values. - Integrated debug feature toggles into the EnvironmentIndicator component, allowing users to enable or disable features dynamically. - Refactored the useFeature hook to accommodate the new debug feature logic. - Improved the EntryLayoutContent and AIEntryLayout components to handle feature visibility based on debug settings. - Introduced a new AIEntryHeader component for better separation of concerns in entry header rendering. These changes collectively enhance the configurability and usability of the debug features, providing users with better control over feature management in the application. Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
parent
3cd4eebddd
commit
699cbfaefc
|
|
@ -2,6 +2,14 @@ import { createAtomHooks } from "@follow/utils/jotai"
|
|||
import { getStorageNS } from "@follow/utils/ns"
|
||||
import { atomWithStorage } from "jotai/utils"
|
||||
|
||||
export const [, , useDebugFeatureValue, getDebugFeatureValue, ,] = createAtomHooks(
|
||||
atomWithStorage(getStorageNS("debug-feature"), {}),
|
||||
)
|
||||
// Shape: { __override?: boolean, [featureKey: string]: boolean }
|
||||
export const [
|
||||
,
|
||||
,
|
||||
useDebugFeatureValue,
|
||||
useSetDebugFeatureValue,
|
||||
getDebugFeatureValue,
|
||||
setDebugFeatureValue,
|
||||
] = createAtomHooks(atomWithStorage<Record<string, unknown>>(getStorageNS("debug-feature"), {}))
|
||||
|
||||
export { useDebugFeatureValue as useDebugFeatures }
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ import { featureConfigMap } from "~/lib/features"
|
|||
export const useFeature = (feature: keyof typeof featureConfigMap) => {
|
||||
const debugFeatureValue = useDebugFeatureValue()
|
||||
const serverConfigs = useServerConfigs()
|
||||
const isEnabled =
|
||||
(featureConfigMap[feature] && serverConfigs?.[featureConfigMap[feature]]) ||
|
||||
debugFeatureValue[feature]
|
||||
const override = !!(debugFeatureValue as any).__override
|
||||
const isEnabled = override
|
||||
? !!(debugFeatureValue as any)[feature]
|
||||
: !!(featureConfigMap[feature] && serverConfigs?.[featureConfigMap[feature]])
|
||||
return isEnabled
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export const EntryLayoutContentWithAI = () => {
|
|||
export const EntryLayoutContent = () => {
|
||||
const aiEnabled = useFeature("ai")
|
||||
if (aiEnabled) {
|
||||
return <EntryLayoutContentWithAI />
|
||||
return null
|
||||
}
|
||||
return <EntryLayoutContentLegacy />
|
||||
}
|
||||
|
|
@ -175,6 +175,6 @@ const EntryGridContainer: FC<
|
|||
</m.div>
|
||||
)
|
||||
} else {
|
||||
return <div className="flex min-w-0 flex-1 flex-col">{children}</div>
|
||||
return <div className="relative flex min-w-0 flex-1 flex-col">{children}</div>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
export { EntryLayoutContent as RightContentLayout } from "./EntryLayoutContent"
|
||||
export { EntryLayoutContent } from "./EntryLayoutContent"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
/* eslint-disable @eslint-react/dom/no-missing-iframe-sandbox */
|
||||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { Switch } from "@follow/components/ui/switch/index.jsx"
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
|
|
@ -11,11 +12,74 @@ import { DEV, MODE } from "@follow/shared/constants"
|
|||
import { env } from "@follow/shared/env.desktop"
|
||||
import { useUserRole } from "@follow/store/user/hooks"
|
||||
|
||||
import { useDebugFeatureValue, useSetDebugFeatureValue } from "~/atoms/debug-feature"
|
||||
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
|
||||
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
|
||||
import { featureConfigMap } from "~/lib/features"
|
||||
|
||||
import { DebugRegistry } from "../debug/registry"
|
||||
|
||||
const EnvironmentDebugModalContent = () => {
|
||||
const actionMap = DebugRegistry.getAll()
|
||||
const debugValues = useDebugFeatureValue() as Record<string, boolean>
|
||||
const setDebugValues = useSetDebugFeatureValue()
|
||||
|
||||
const overrideEnabled = !!debugValues.__override
|
||||
|
||||
const handleToggleOverride = (checked: boolean) => {
|
||||
setDebugValues((prev) => ({ ...(prev as Record<string, boolean>), __override: checked }))
|
||||
}
|
||||
|
||||
const handleToggleFeature = (key: string, checked: boolean) => {
|
||||
setDebugValues((prev) => ({ ...(prev as Record<string, boolean>), [key]: checked }))
|
||||
}
|
||||
|
||||
const featureKeys = Object.keys(featureConfigMap)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-text text-sm font-medium">Debug override features</div>
|
||||
<Switch checked={overrideEnabled} onCheckedChange={handleToggleOverride} />
|
||||
</div>
|
||||
<p className="text-text-secondary text-xs">
|
||||
When enabled, the switches below override server feature flags locally.
|
||||
</p>
|
||||
<div className="bg-material-medium rounded-md p-2">
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{featureKeys.map((key) => (
|
||||
<div key={key} className="flex items-center justify-between rounded-md p-2">
|
||||
<span className="text-text text-sm">{key}</span>
|
||||
<Switch
|
||||
checked={!!debugValues[key]}
|
||||
onCheckedChange={(v) => handleToggleFeature(key, v)}
|
||||
disabled={!overrideEnabled}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="text-text text-sm font-medium">Debug actions</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.entries(actionMap).map(([key, action]) => (
|
||||
<div key={key} className="flex w-full items-center gap-2">
|
||||
<span className="flex flex-1">{key}</span>
|
||||
<Button variant="outline" type="button" onClick={() => action()}>
|
||||
<i className="i-mgc-play-cute-fi size-3" />
|
||||
<span className="ml-1">Run</span>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const EnvironmentIndicator = () => {
|
||||
const role = useUserRole()
|
||||
const { present } = useModalStack()
|
||||
|
|
@ -29,27 +93,9 @@ export const EnvironmentIndicator = () => {
|
|||
onClick={() => {
|
||||
if (!DEV) return
|
||||
|
||||
const actionMap = DebugRegistry.getAll()
|
||||
|
||||
present({
|
||||
title: "Debug Actions",
|
||||
content: () => {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.entries(actionMap).map(([key, action]) => {
|
||||
return (
|
||||
<div key={key} className="flex w-full items-center gap-2">
|
||||
<span className="flex flex-1">{key}</span>
|
||||
<Button variant="outline" type="button" onClick={() => action()}>
|
||||
<i className="i-mgc-play-cute-fi size-3" />
|
||||
<span className="ml-1">Run</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
content: EnvironmentDebugModalContent,
|
||||
})
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
import { Spring } from "@follow/components/constants/spring.js"
|
||||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { PanelSplitter } from "@follow/components/ui/divider/index.js"
|
||||
import { defaultUISettings } from "@follow/shared/settings/defaults"
|
||||
import { cn } from "@follow/utils"
|
||||
import { AnimatePresence, m } from "motion/react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useMemo, useRef } from "react"
|
||||
import { useResizable } from "react-resizable-layout"
|
||||
import { useParams } from "react-router"
|
||||
|
||||
import { AIChatPanelStyle, useAIChatPanelStyle } from "~/atoms/settings/ai"
|
||||
import { getUISettings, setUISetting } from "~/atoms/settings/ui"
|
||||
import { ROUTE_ENTRY_PENDING } from "~/constants"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { AIChatLayout } from "~/modules/app-layout/ai/AIChatLayout"
|
||||
import { EntryContent } from "~/modules/entry-content/components/entry-content"
|
||||
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
|
||||
|
|
@ -21,95 +19,11 @@ import { EntryColumn } from "./index"
|
|||
|
||||
const AIEntryLayoutImpl = () => {
|
||||
const { entryId } = useParams()
|
||||
const navigate = useNavigateEntry()
|
||||
|
||||
const panelStyle = useAIChatPanelStyle()
|
||||
|
||||
const realEntryId = entryId === ROUTE_ENTRY_PENDING ? "" : entryId
|
||||
|
||||
// Swipe/scroll to close functionality
|
||||
const entryContentRef = useRef<HTMLDivElement>(null)
|
||||
const accumulatedDelta = useRef(0)
|
||||
const isScrollingAtTop = useRef(false)
|
||||
const [showScrollHint, setShowScrollHint] = useState(false)
|
||||
|
||||
const handleCloseGesture = useCallback(() => {
|
||||
navigate({ entryId: null })
|
||||
}, [navigate])
|
||||
|
||||
const handleWheel = useCallback(
|
||||
(e: WheelEvent) => {
|
||||
if (!realEntryId || !entryContentRef.current) return
|
||||
|
||||
// Find the actual scroll viewport element with correct Radix UI attribute
|
||||
const entryContentElement = entryContentRef.current.querySelector(
|
||||
"[data-radix-scroll-area-viewport]",
|
||||
) as HTMLElement
|
||||
const scrollElement = entryContentElement || entryContentRef.current
|
||||
|
||||
// Check if we're at the top of the content
|
||||
const scrollTop = scrollElement?.scrollTop || 0
|
||||
isScrollingAtTop.current = scrollTop === 0
|
||||
setShowScrollHint(scrollTop === 0)
|
||||
|
||||
// Handle trackpad/mouse wheel: upward scroll (deltaY < 0) or downward swipe gesture
|
||||
// On macOS trackpad, natural scrolling makes upward finger movement negative deltaY
|
||||
if (e.deltaY < 0 && isScrollingAtTop.current) {
|
||||
e.preventDefault()
|
||||
accumulatedDelta.current += Math.abs(e.deltaY)
|
||||
|
||||
// Close when accumulated scroll exceeds threshold (150px for trackpad sensitivity)
|
||||
if (accumulatedDelta.current > 1000) {
|
||||
handleCloseGesture()
|
||||
accumulatedDelta.current = 0
|
||||
}
|
||||
} else {
|
||||
// Reset accumulation when scrolling down or not at top
|
||||
accumulatedDelta.current = 0
|
||||
}
|
||||
},
|
||||
[realEntryId, handleCloseGesture],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!realEntryId || !entryContentRef.current) return
|
||||
|
||||
const element = entryContentRef.current
|
||||
|
||||
// Find the scroll area viewport element with correct Radix UI attribute
|
||||
const scrollViewport = element.querySelector("[data-radix-scroll-area-viewport]") as HTMLElement
|
||||
|
||||
// Add wheel event listener to both the main container and scroll viewport
|
||||
// This ensures the gesture works in both header area and scrollable content
|
||||
const elementsToListen: HTMLElement[] = [element]
|
||||
if (scrollViewport) {
|
||||
elementsToListen.push(scrollViewport)
|
||||
}
|
||||
|
||||
elementsToListen.forEach((el) => {
|
||||
el.addEventListener("wheel", handleWheel, { passive: false })
|
||||
})
|
||||
|
||||
// Initial scroll position check for hint visibility
|
||||
const initialCheckScrollPosition = () => {
|
||||
const scrollTop = scrollViewport?.scrollTop || element.scrollTop || 0
|
||||
setShowScrollHint(scrollTop === 0)
|
||||
}
|
||||
|
||||
// Check initial position
|
||||
initialCheckScrollPosition()
|
||||
|
||||
// Add scroll listener for hint visibility
|
||||
const scrollElement = scrollViewport || element
|
||||
scrollElement.addEventListener("scroll", initialCheckScrollPosition, { passive: true })
|
||||
|
||||
return () => {
|
||||
elementsToListen.forEach((el) => {
|
||||
el.removeEventListener("wheel", handleWheel)
|
||||
})
|
||||
scrollElement.removeEventListener("scroll", initialCheckScrollPosition)
|
||||
}
|
||||
}, [realEntryId, handleWheel])
|
||||
|
||||
// AI chat resizable panel configuration
|
||||
const aiColWidth = useMemo(() => getUISettings().aiColWidth, [])
|
||||
const startDragPosition = useRef(0)
|
||||
|
|
@ -139,38 +53,16 @@ const AIEntryLayoutImpl = () => {
|
|||
<EntryColumn key="entry-list" />
|
||||
|
||||
{/* Entry content overlay with exit animation */}
|
||||
<AnimatePresence mode="wait">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{realEntryId && (
|
||||
<m.div
|
||||
ref={entryContentRef}
|
||||
key={realEntryId}
|
||||
initial={{ y: "100%" }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: "100%" }}
|
||||
transition={Spring.presets.smooth}
|
||||
className="bg-theme-background absolute inset-0 z-10 border-l"
|
||||
>
|
||||
{/* Scroll hint indicator */}
|
||||
<div className="center z-50 pt-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate({ entryId: null })}
|
||||
buttonClassName="transform cursor-pointer select-none"
|
||||
aria-label="Scroll up or click to exit"
|
||||
>
|
||||
<div className="text-text flex items-center gap-2 rounded-full font-medium">
|
||||
<i
|
||||
className={cn(
|
||||
"text-base",
|
||||
showScrollHint ? "i-mgc-up-cute-re" : "i-mgc-close-cute-re",
|
||||
)}
|
||||
/>
|
||||
<span>{showScrollHint ? "Scroll up to exit" : "Click to exit"}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
<EntryContent entryId={realEntryId} className="h-[calc(100%-2.25rem)]" />
|
||||
<EntryContent entryId={realEntryId} className="h-full" />
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { FeedViewType } from "@follow/constants"
|
||||
import { useEntry } from "@follow/store/entry/hooks"
|
||||
import { useHasEntry } from "@follow/store/entry/hooks"
|
||||
import { useEntryTranslation, usePrefetchEntryTranslation } from "@follow/store/translation/hooks"
|
||||
import type { FC } from "react"
|
||||
import { memo } from "react"
|
||||
|
|
@ -47,9 +47,9 @@ const EntryItemImpl = memo(function EntryItemImpl({
|
|||
})
|
||||
|
||||
export const EntryItem: FC<EntryItemProps> = memo(({ entryId, view }) => {
|
||||
const entry = useEntry(entryId, () => ({}))
|
||||
const hasEntry = useHasEntry(entryId)
|
||||
|
||||
if (!entry) return null
|
||||
if (!hasEntry) return null
|
||||
return <EntryItemImpl entryId={entryId} view={view} />
|
||||
})
|
||||
|
||||
|
|
@ -63,9 +63,9 @@ export const EntryVirtualListItem = ({
|
|||
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
ref?: React.Ref<HTMLDivElement | null>
|
||||
}) => {
|
||||
const entry = useEntry(entryId, () => ({}))
|
||||
const hasEntry = useHasEntry(entryId)
|
||||
|
||||
if (!entry) return <div ref={ref} {...props} style={undefined} />
|
||||
if (!hasEntry) return <div ref={ref} {...props} style={undefined} />
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 top-0 w-full will-change-transform" ref={ref} {...props}>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { TitleMarquee } from "@follow/components/ui/marquee/index.jsx"
|
||||
import { useIsEntryStarred } from "@follow/store/collection/hooks"
|
||||
import { useEntry } from "@follow/store/entry/hooks"
|
||||
import { useEntry, useHasEntry } from "@follow/store/entry/hooks"
|
||||
import { useFeedById } from "@follow/store/feed/hooks"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import dayjs from "dayjs"
|
||||
|
|
@ -21,9 +21,9 @@ interface GridItemProps extends UniversalItemProps {
|
|||
}
|
||||
export function GridItem(props: GridItemProps) {
|
||||
const { entryId, entryPreview, wrapperClassName, children, translation } = props
|
||||
const entry = useEntry(entryId, () => ({}))
|
||||
const hasEntry = useHasEntry(entryId)
|
||||
|
||||
if (!entry) return null
|
||||
if (!hasEntry) return null
|
||||
return (
|
||||
<div className={cn("p-1.5", wrapperClassName)}>
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -17,15 +17,15 @@ export const EntryTimelineSidebar = ({ entryId }: { entryId: string }) => {
|
|||
return null
|
||||
}
|
||||
|
||||
return <Timeline entryId={entryId} />
|
||||
return <EntryTimeline entryId={entryId} />
|
||||
}
|
||||
|
||||
const Timeline = ({ entryId }: { entryId: string }) => {
|
||||
export const EntryTimeline = ({ entryId }: { entryId: string }) => {
|
||||
const entryIds = useGetEntryIdInRange(entryId, [5, 5])
|
||||
|
||||
return (
|
||||
<m.div
|
||||
className="@lg:max-w-0 @6xl:max-w-[200px] @7xl:max-w-[200px] @[90rem]:max-w-[250px] absolute left-8 top-28 z-10"
|
||||
className="@lg:hidden @6xl:block @6xl:max-w-[200px] @7xl:max-w-[200px] @[90rem]:max-w-[250px] absolute left-8 top-28 z-10"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { delay: 0.5 } }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { Spring } from "@follow/components/constants/spring.js"
|
||||
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
|
||||
import { RootPortal } from "@follow/components/ui/portal/index.js"
|
||||
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
|
||||
|
|
@ -10,8 +11,9 @@ import { useIsInbox } from "@follow/store/inbox/hooks"
|
|||
import { thenable } from "@follow/utils"
|
||||
import { stopPropagation } from "@follow/utils/dom"
|
||||
import { EventBus } from "@follow/utils/event-bus"
|
||||
import { clsx, cn } from "@follow/utils/utils"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import type { JSAnimation } from "motion/react"
|
||||
import { m, useAnimationControls } from "motion/react"
|
||||
import * as React from "react"
|
||||
import { memo, useEffect, useRef, useState } from "react"
|
||||
|
||||
|
|
@ -28,8 +30,8 @@ import { COMMAND_ID } from "~/modules/command/commands/id"
|
|||
|
||||
import { ApplyEntryActions } from "../../ApplyEntryActions"
|
||||
import { useEntryContent } from "../../hooks"
|
||||
import { EntryHeader } from "../entry-header"
|
||||
import { EntryTimelineSidebar } from "../EntryTimelineSidebar"
|
||||
import { AIEntryHeader } from "../entry-header"
|
||||
import { EntryTimeline } from "../EntryTimelineSidebar"
|
||||
import { getEntryContentLayout } from "../layouts"
|
||||
import { SourceContentPanel } from "../SourceContentView"
|
||||
import { EntryCommandShortcutRegister } from "./EntryCommandShortcutRegister"
|
||||
|
|
@ -38,6 +40,11 @@ import { EntryNoContent } from "./EntryNoContent"
|
|||
import { EntryScrollingAndNavigationHandler } from "./EntryScrollingAndNavigationHandler.js"
|
||||
import type { EntryContentProps } from "./types"
|
||||
|
||||
const contentVariants = {
|
||||
initial: { opacity: 0, y: 30 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, y: 30 },
|
||||
}
|
||||
const EntryContentImpl: Component<EntryContentProps> = ({
|
||||
entryId,
|
||||
noMedia,
|
||||
|
|
@ -65,7 +72,6 @@ const EntryContentImpl: Component<EntryContentProps> = ({
|
|||
const scrollerRef = useRef<HTMLDivElement | null>(null)
|
||||
const safeUrl = useFeedSafeUrl(entryId)
|
||||
|
||||
const isInPeekModal = useInPeekModal()
|
||||
const isZenMode = useIsZenMode()
|
||||
|
||||
const [panelPortalElement, setPanelPortalElement] = useState<HTMLDivElement | null>(null)
|
||||
|
|
@ -89,18 +95,30 @@ const EntryContentImpl: Component<EntryContentProps> = ({
|
|||
removeBlock(BlockSliceAction.SPECIAL_TYPES.mainEntry)
|
||||
}
|
||||
}, [addOrUpdateBlock, entryId, removeBlock])
|
||||
const animationController = useAnimationControls()
|
||||
|
||||
useEffect(() => {
|
||||
animationController.set(contentVariants.exit)
|
||||
animationController.start(contentVariants.animate)
|
||||
return () => {
|
||||
animationController.stop()
|
||||
}
|
||||
}, [animationController, entryId])
|
||||
|
||||
return (
|
||||
<div className={cn(className, "@container flex flex-col")}>
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={animationController}
|
||||
transition={Spring.presets.smooth}
|
||||
className={cn(className, "@container flex flex-col")}
|
||||
>
|
||||
<EntryCommandShortcutRegister entryId={entryId} view={view} />
|
||||
{!isInPeekModal && (
|
||||
<EntryHeader
|
||||
entryId={entryId}
|
||||
view={view}
|
||||
className={cn("@container h-[55px] shrink-0 px-3", classNames?.header)}
|
||||
compact={compact}
|
||||
/>
|
||||
)}
|
||||
<AIEntryHeader
|
||||
entryId={entryId}
|
||||
view={view}
|
||||
className={cn("@container h-[55px] shrink-0 px-3", classNames?.header)}
|
||||
compact={compact}
|
||||
/>
|
||||
<div className="w-full" ref={setPanelPortalElement} />
|
||||
|
||||
<Focusable
|
||||
|
|
@ -113,13 +131,13 @@ const EntryContentImpl: Component<EntryContentProps> = ({
|
|||
scrollerRef={scrollerRef}
|
||||
/>
|
||||
</RootPortal>
|
||||
<EntryTimelineSidebar entryId={entryId} />
|
||||
<EntryTimeline entryId={entryId} />
|
||||
<EntryScrollArea scrollerRef={scrollerRef}>
|
||||
{/* Indicator for the entry */}
|
||||
<div className="select-text">
|
||||
{!isZenMode && isInHasTimelineView && !isInPeekModal && (
|
||||
{!isZenMode && isInHasTimelineView && (
|
||||
<>
|
||||
<div className="absolute inset-y-0 left-0 flex w-12 items-center justify-center opacity-0 duration-200 hover:opacity-100">
|
||||
<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"
|
||||
|
|
@ -131,7 +149,7 @@ const EntryContentImpl: Component<EntryContentProps> = ({
|
|||
</MotionButtonBase>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-y-0 right-0 flex w-12 items-center justify-center opacity-0 duration-200 hover:opacity-100">
|
||||
<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={() => {
|
||||
|
|
@ -147,10 +165,7 @@ const EntryContentImpl: Component<EntryContentProps> = ({
|
|||
<article
|
||||
data-testid="entry-render"
|
||||
onContextMenu={stopPropagation}
|
||||
className={clsx(
|
||||
"relative w-full min-w-0 pb-10 pt-2",
|
||||
isInPeekModal ? "max-w-full" : view === FeedViewType.Articles ? "" : "max-w-full",
|
||||
)}
|
||||
className={"relative w-full min-w-0 pb-10 pt-2"}
|
||||
>
|
||||
<ApplyEntryActions entryId={entryId} key={entryId} />
|
||||
|
||||
|
|
@ -187,7 +202,7 @@ const EntryContentImpl: Component<EntryContentProps> = ({
|
|||
</Focusable>
|
||||
|
||||
{/* <React.Suspense>{!isInPeekModal && <AISmartSidebar entryId={entryId} />}</React.Suspense> */}
|
||||
</div>
|
||||
</m.div>
|
||||
)
|
||||
}
|
||||
export const EntryContent = memo(EntryContentImpl)
|
||||
|
|
|
|||
|
|
@ -2,14 +2,16 @@ import {
|
|||
useFocusActions,
|
||||
useGlobalFocusableScopeSelector,
|
||||
} from "@follow/components/common/Focusable/index.js"
|
||||
import { Spring } from "@follow/components/constants/spring.js"
|
||||
import { useSmoothScroll } from "@follow/hooks"
|
||||
import { nextFrame } from "@follow/utils/dom"
|
||||
import { EventBus } from "@follow/utils/event-bus"
|
||||
import { cn, combineCleanupFunctions } from "@follow/utils/utils"
|
||||
import { clsx, combineCleanupFunctions } from "@follow/utils/utils"
|
||||
import type { JSAnimation } from "motion/react"
|
||||
import { AnimatePresence, m } from "motion/react"
|
||||
import * as React from "react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { useEventCallback } from "usehooks-ts"
|
||||
|
||||
import { FocusablePresets } from "~/components/common/Focusable"
|
||||
import { COMMAND_ID } from "~/modules/command/commands/id"
|
||||
|
|
@ -56,16 +58,21 @@ export const EntryScrollingAndNavigationHandler = ({
|
|||
|
||||
const { highlightBoundary } = useFocusActions()
|
||||
const smoothScrollTo = useSmoothScroll()
|
||||
const navigateToNext = useEventCallback(() => {
|
||||
EventBus.dispatch(COMMAND_ID.timeline.switchToNext)
|
||||
setShowKeepScrollingPanel(false)
|
||||
isAlreadyScrolledBottomRef.current = false
|
||||
if (scrollerRef.current) {
|
||||
smoothScrollTo(0, scrollerRef.current)
|
||||
}
|
||||
})
|
||||
useEffect(() => {
|
||||
const checkScrollBottom = ($scroller: HTMLDivElement) => {
|
||||
const currentScroll = $scroller.scrollTop
|
||||
const { scrollHeight, clientHeight } = $scroller
|
||||
|
||||
if (isAlreadyScrolledBottomRef.current) {
|
||||
EventBus.dispatch(COMMAND_ID.timeline.switchToNext)
|
||||
setShowKeepScrollingPanel(false)
|
||||
isAlreadyScrolledBottomRef.current = false
|
||||
smoothScrollTo(0, $scroller)
|
||||
navigateToNext()
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -140,42 +147,38 @@ export const EntryScrollingAndNavigationHandler = ({
|
|||
},
|
||||
),
|
||||
)
|
||||
}, [highlightBoundary, scrollAnimationRef, scrollerRef, smoothScrollTo])
|
||||
}, [highlightBoundary, navigateToNext, scrollAnimationRef, scrollerRef, smoothScrollTo])
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{showKeepScrollingPanel && (
|
||||
<FloatPanel side="bottom">
|
||||
Already scrolled to the bottom.
|
||||
<br />
|
||||
Keep pressing to jump to the next article
|
||||
</FloatPanel>
|
||||
<m.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={Spring.presets.smooth}
|
||||
className={clsx(
|
||||
"pointer-events-none absolute !right-1/2 z-40 !translate-x-1/2",
|
||||
"bottom-12",
|
||||
"backdrop-blur-background rounded-full border px-3.5 py-2",
|
||||
"border-border/40 bg-material-ultra-thin/70 shadow-[0_1px_2px_rgba(0,0,0,0.06),0_8px_24px_rgba(0,0,0,0.08)]",
|
||||
"hover:bg-material-thin/70 hover:border-border/60 active:scale-[0.98]",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
onClick={navigateToNext}
|
||||
type="button"
|
||||
className={"group pointer-events-auto flex items-center gap-2"}
|
||||
>
|
||||
<i className="i-mingcute-arrow-down-fill text-text/90 mr-1 size-5" />
|
||||
<span className="text-text/90 text-left text-[13px] font-medium">
|
||||
Already scrolled to the bottom.
|
||||
<br />
|
||||
Keep pressing to jump to the next article
|
||||
</span>
|
||||
</button>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
const FloatPanel: React.FC<{ children: React.ReactNode; side: "bottom" | "top" }> = ({
|
||||
children,
|
||||
side,
|
||||
}) => (
|
||||
<m.div
|
||||
initial={{ opacity: 0, y: 32 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 32 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className={cn(
|
||||
"bg-material-ultra-thick text-text backdrop-blur-background absolute left-1/2 z-50 -translate-x-1/2 select-none rounded-2xl px-6 py-3 text-center text-[15px] font-medium shadow-xl",
|
||||
side === "bottom" ? "bottom-8" : "top-8",
|
||||
)}
|
||||
style={{
|
||||
boxShadow: "0 4px 24px 0 rgba(0,0,0,0.10), 0 1.5px 4px 0 rgba(0,0,0,0.08)",
|
||||
WebkitBackdropFilter: "blur(16px)",
|
||||
backdropFilter: "blur(16px)",
|
||||
maxWidth: 360,
|
||||
width: "calc(100vw - 32px)",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</m.div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import { memo } from "react"
|
||||
|
||||
import { BaseEntryHeader } from "./internal/BaseEntryHeader"
|
||||
import type { EntryHeaderProps } from "./types"
|
||||
|
||||
function EntryHeaderImpl({ view, entryId, className, compact }: EntryHeaderProps) {
|
||||
return (
|
||||
<BaseEntryHeader
|
||||
view={view}
|
||||
entryId={entryId}
|
||||
className={className}
|
||||
compact={compact}
|
||||
config={{ alwaysShowActions: true }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export const AIEntryHeader = memo(EntryHeaderImpl)
|
||||
|
|
@ -1,86 +1,17 @@
|
|||
import { views } from "@follow/constants"
|
||||
import { useEntry } from "@follow/store/entry/hooks"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { AnimatePresence, m } from "motion/react"
|
||||
import { memo } from "react"
|
||||
|
||||
import { useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { useFeature } from "~/hooks/biz/useFeature"
|
||||
|
||||
import { EntryHeaderActions } from "../../actions/header-actions"
|
||||
import { MoreActions } from "../../actions/more-actions"
|
||||
import { useEntryContentScrollToTop, useEntryTitleMeta } from "../../atoms"
|
||||
import { EntryReadHistory } from "../entry-read-history"
|
||||
import { BaseEntryHeader } from "./internal/BaseEntryHeader"
|
||||
import type { EntryHeaderProps } from "./types"
|
||||
|
||||
function EntryHeaderImpl({ view, entryId, className, compact }: EntryHeaderProps) {
|
||||
const entry = useEntry(entryId, () => ({}))
|
||||
const entryTitleMeta = useEntryTitleMeta()
|
||||
const isAtTop = useEntryContentScrollToTop()
|
||||
|
||||
const hideRecentReader = useUISettingKey("hideRecentReader")
|
||||
|
||||
const shouldShowMeta = !isAtTop && !!entryTitleMeta?.title
|
||||
|
||||
const aiEnabled = useFeature("ai")
|
||||
const isWide = views[view]?.wideMode || aiEnabled
|
||||
|
||||
if (!entry) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-hide-in-print
|
||||
className={cn(
|
||||
"zen-mode-macos:ml-margin-macos-traffic-light-x text-text-secondary relative flex min-w-0 items-center justify-between gap-3 overflow-hidden text-lg duration-200",
|
||||
shouldShowMeta && "border-border border-b",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{!hideRecentReader && (
|
||||
<div
|
||||
className={cn(
|
||||
"zen-mode-macos:left-12 text-body absolute left-5 top-0 flex h-full items-center gap-2 leading-none",
|
||||
"visible z-[11]",
|
||||
views[view]!.wideMode && "static",
|
||||
shouldShowMeta && "hidden",
|
||||
)}
|
||||
>
|
||||
<EntryReadHistory entryId={entryId} />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="relative z-10 flex w-full items-center justify-between gap-3"
|
||||
data-hide-in-print
|
||||
>
|
||||
<div className="flex min-w-0 shrink grow">
|
||||
<AnimatePresence>
|
||||
{shouldShowMeta && (
|
||||
<m.div
|
||||
initial={{ opacity: 0.01, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0.01, y: 30 }}
|
||||
className="text-text text-title3 flex min-w-0 shrink items-end gap-2 truncate leading-tight"
|
||||
>
|
||||
<span className="min-w-[50%] shrink truncate font-bold">
|
||||
{entryTitleMeta.title}
|
||||
</span>
|
||||
<i className="i-mgc-line-cute-re text-text-secondary size-[10px] shrink-0 translate-y-[-3px] rotate-[-25deg]" />
|
||||
<span className="text-text-secondary text-headline shrink -translate-y-px truncate">
|
||||
{entryTitleMeta.description}
|
||||
</span>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{!isWide && (
|
||||
<div className="relative flex shrink-0 items-center justify-end gap-2">
|
||||
<EntryHeaderActions entryId={entryId} view={view} compact={compact} />
|
||||
<MoreActions entryId={entryId} view={view} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<BaseEntryHeader
|
||||
view={view}
|
||||
entryId={entryId}
|
||||
className={className}
|
||||
compact={compact}
|
||||
config={{ showActionsWhenWide: false }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from "./AIEntryHeader"
|
||||
export * from "./EntryHeader"
|
||||
export * from "./types"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import type { FeedViewType } from "@follow/constants"
|
||||
import { views } from "@follow/constants"
|
||||
import { useHasEntry } from "@follow/store/entry/hooks"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { memo } from "react"
|
||||
|
||||
import { useUISettingKey } from "~/atoms/settings/ui"
|
||||
|
||||
import { useEntryContentScrollToTop, useEntryTitleMeta } from "../../../atoms"
|
||||
import { EntryHeaderActionsContainer } from "./EntryHeaderActionsContainer"
|
||||
import { EntryHeaderMeta } from "./EntryHeaderMeta"
|
||||
import { EntryHeaderReadHistory } from "./EntryHeaderReadHistory"
|
||||
|
||||
interface EntryHeaderConfig {
|
||||
showActionsWhenWide?: boolean
|
||||
alwaysShowActions?: boolean
|
||||
}
|
||||
|
||||
interface BaseEntryHeaderProps {
|
||||
view: FeedViewType
|
||||
entryId: string
|
||||
className?: string
|
||||
compact?: boolean
|
||||
config?: EntryHeaderConfig
|
||||
}
|
||||
|
||||
function BaseEntryHeaderImpl({ view, entryId, className, compact, config }: BaseEntryHeaderProps) {
|
||||
const hasEntry = useHasEntry(entryId)
|
||||
const entryTitleMeta = useEntryTitleMeta()
|
||||
const isAtTop = useEntryContentScrollToTop()
|
||||
const hideRecentReader = useUISettingKey("hideRecentReader")
|
||||
|
||||
const shouldShowMeta = !isAtTop && !!entryTitleMeta?.title
|
||||
const isWide = views[view]?.wideMode
|
||||
|
||||
const shouldShowActions =
|
||||
config?.alwaysShowActions ?? (config?.showActionsWhenWide ? true : !isWide)
|
||||
|
||||
if (!hasEntry) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-hide-in-print
|
||||
className={cn(
|
||||
"zen-mode-macos:ml-margin-macos-traffic-light-x text-text-secondary relative flex min-w-0 items-center justify-between gap-3 overflow-hidden text-lg duration-200",
|
||||
shouldShowMeta && "border-border border-b",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<EntryHeaderReadHistory
|
||||
entryId={entryId}
|
||||
view={view}
|
||||
shouldShow={!hideRecentReader}
|
||||
shouldHide={shouldShowMeta}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="relative z-10 flex w-full items-center justify-between gap-3"
|
||||
data-hide-in-print
|
||||
>
|
||||
<EntryHeaderMeta entryTitleMeta={entryTitleMeta} shouldShow={shouldShowMeta} />
|
||||
|
||||
<EntryHeaderActionsContainer
|
||||
entryId={entryId}
|
||||
view={view}
|
||||
compact={compact}
|
||||
shouldShow={shouldShowActions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const BaseEntryHeader = memo(BaseEntryHeaderImpl)
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import type { FeedViewType } from "@follow/constants"
|
||||
import { memo } from "react"
|
||||
|
||||
import { EntryHeaderActions } from "../../../actions/header-actions"
|
||||
import { MoreActions } from "../../../actions/more-actions"
|
||||
|
||||
interface EntryHeaderActionsContainerProps {
|
||||
entryId: string
|
||||
view: FeedViewType
|
||||
compact?: boolean
|
||||
shouldShow: boolean
|
||||
}
|
||||
|
||||
function EntryHeaderActionsContainerImpl({
|
||||
entryId,
|
||||
view,
|
||||
compact,
|
||||
shouldShow,
|
||||
}: EntryHeaderActionsContainerProps) {
|
||||
if (!shouldShow) return null
|
||||
|
||||
return (
|
||||
<div className="relative flex shrink-0 items-center justify-end gap-2">
|
||||
<EntryHeaderActions entryId={entryId} view={view} compact={compact} />
|
||||
<MoreActions entryId={entryId} view={view} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const EntryHeaderActionsContainer = memo(EntryHeaderActionsContainerImpl)
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import { AnimatePresence, m } from "motion/react"
|
||||
import { memo } from "react"
|
||||
|
||||
interface EntryTitleMeta {
|
||||
title: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
interface EntryHeaderMetaProps {
|
||||
entryTitleMeta: Nullable<EntryTitleMeta>
|
||||
shouldShow: boolean
|
||||
}
|
||||
|
||||
function EntryHeaderMetaImpl({ entryTitleMeta, shouldShow }: EntryHeaderMetaProps) {
|
||||
return (
|
||||
<div className="flex min-w-0 shrink grow">
|
||||
<AnimatePresence>
|
||||
{shouldShow && entryTitleMeta && (
|
||||
<m.div
|
||||
initial={{ opacity: 0.01, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0.01, y: 30 }}
|
||||
className="text-text text-title3 flex min-w-0 flex-1 shrink items-end gap-2 truncate leading-tight"
|
||||
>
|
||||
<span className="shrink truncate font-bold">{entryTitleMeta.title}</span>
|
||||
<i className="i-mgc-line-cute-re text-text-secondary size-[10px] shrink-0 translate-y-[-3px] rotate-[-25deg]" />
|
||||
<span className="text-text-secondary text-headline shrink -translate-y-px truncate">
|
||||
{entryTitleMeta.description}
|
||||
</span>
|
||||
</m.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const EntryHeaderMeta = memo(EntryHeaderMetaImpl)
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import type { FeedViewType } from "@follow/constants"
|
||||
import { views } from "@follow/constants"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { memo } from "react"
|
||||
|
||||
import { EntryReadHistory } from "../../entry-read-history"
|
||||
|
||||
interface EntryHeaderReadHistoryProps {
|
||||
entryId: string
|
||||
view: FeedViewType
|
||||
shouldShow: boolean
|
||||
shouldHide: boolean
|
||||
}
|
||||
|
||||
function EntryHeaderReadHistoryImpl({
|
||||
entryId,
|
||||
view,
|
||||
shouldShow,
|
||||
shouldHide,
|
||||
}: EntryHeaderReadHistoryProps) {
|
||||
if (!shouldShow) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"zen-mode-macos:left-12 text-body absolute left-5 top-0 flex h-full items-center gap-2 leading-none",
|
||||
"visible z-[11]",
|
||||
views[view]?.wideMode && "static",
|
||||
shouldHide && "hidden",
|
||||
)}
|
||||
>
|
||||
<EntryReadHistory entryId={entryId} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const EntryHeaderReadHistory = memo(EntryHeaderReadHistoryImpl)
|
||||
|
|
@ -1 +1 @@
|
|||
export { RightContentLayout as Component } from "~/modules/app-layout/entry-content/index"
|
||||
export { EntryLayoutContent as Component } from "~/modules/app-layout/entry-content/index"
|
||||
|
|
|
|||
|
|
@ -140,7 +140,9 @@ export function useEntry<T>(
|
|||
return selector(entry)
|
||||
})
|
||||
}
|
||||
|
||||
export const useHasEntry = (id: string) => {
|
||||
return useEntryStore((state) => !!state.data[id])
|
||||
}
|
||||
export function useEntryList(ids: string[]): Array<EntryModel | null>
|
||||
export function useEntryList<T>(ids: string[], selector: (state: EntryModel) => T): T[] | undefined
|
||||
export function useEntryList(
|
||||
|
|
|
|||
Loading…
Reference in New Issue