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:
Innei 2025-08-13 01:46:19 +08:00
parent 3cd4eebddd
commit 699cbfaefc
No known key found for this signature in database
GPG Key ID: 0F62D33977F021F7
20 changed files with 383 additions and 288 deletions

View File

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

View File

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

View File

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

View File

@ -1 +1 @@
export { EntryLayoutContent as RightContentLayout } from "./EntryLayoutContent"
export { EntryLayoutContent } from "./EntryLayoutContent"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,2 +1,3 @@
export * from "./AIEntryHeader"
export * from "./EntryHeader"
export * from "./types"

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +1 @@
export { RightContentLayout as Component } from "~/modules/app-layout/entry-content/index"
export { EntryLayoutContent as Component } from "~/modules/app-layout/entry-content/index"

View File

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