feat: revert entry animation and entry header

This commit is contained in:
DIYgod 2025-08-17 12:54:54 +08:00
parent f5d7cbceb5
commit f3a4eab8af
No known key found for this signature in database
14 changed files with 156 additions and 871 deletions

View File

@ -1,9 +1,10 @@
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 } from "motion/react"
import { useMemo, useRef } from "react"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useResizable } from "react-resizable-layout"
import { useParams } from "react-router"
@ -11,6 +12,7 @@ import { AIChatPanelStyle, useAIChatPanelStyle } from "~/atoms/settings/ai"
import { getUISettings, setUISetting } from "~/atoms/settings/ui"
import { m } from "~/components/common/Motion"
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"
@ -20,11 +22,95 @@ 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)
@ -54,17 +140,39 @@ const AIEntryLayoutImpl = () => {
<EntryColumn key="entry-list" />
{/* Entry content overlay with exit animation */}
<AnimatePresence mode="popLayout">
<AnimatePresence mode="wait">
{realEntryId && (
<m.div
lcpOptimization
initial={{ y: 150, opacity: 0, scale: 0.98 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
exit={{ y: 150, opacity: 0, scale: 0.98 }}
transition={Spring.presets.smooth}
ref={entryContentRef}
key={realEntryId}
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={Spring.presets.microRebound}
className="bg-theme-background absolute inset-0 z-10 border-l"
>
<EntryContent entryId={realEntryId} className="h-full" />
{/* 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)]" />
</m.div>
)}
</AnimatePresence>

View File

@ -1,100 +0,0 @@
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
import { throttle } from "es-toolkit"
import { useEffect, useRef, useState } from "react"
import { useEventCallback } from "usehooks-ts"
interface UseWheelGestureCloseOptions {
/** Whether the gesture is enabled */
enabled: boolean
/** Callback to execute when close gesture is triggered */
onClose: () => void
}
interface UseWheelGestureCloseReturn {
/** Whether to show scroll hint indicator */
showScrollHint: boolean
}
/**
* Custom hook for handling wheel gesture to close entry
* Handles trackpad/mouse wheel upward scroll when at top of content
*/
export const useWheelGestureClose = ({
enabled,
onClose: handleCloseGesture,
}: UseWheelGestureCloseOptions): UseWheelGestureCloseReturn => {
const $scrollAreaElement = useScrollViewElement()
const accumulatedDelta = useRef(0)
const isScrollingAtTop = useRef(false)
const [showScrollHint, setShowScrollHint] = useState(false)
const handleWheel = useEventCallback(
throttle((e: WheelEvent) => {
if (!enabled) return
// Find the actual scroll viewport element with correct Radix UI attribute
const scrollElement = $scrollAreaElement
// 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
}
}, 16),
)
useEffect(() => {
if (!$scrollAreaElement) return
// Find the scroll area viewport element with correct Radix UI attribute
// 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[] = [$scrollAreaElement]
elementsToListen.forEach((el) => {
el.addEventListener("wheel", handleWheel, { passive: false })
})
// Initial scroll position check for hint visibility
const initialCheckScrollPosition = () => {
if (!$scrollAreaElement) return
const scrollTop = $scrollAreaElement.scrollTop || 0
setShowScrollHint(scrollTop === 0)
}
// Check initial position
initialCheckScrollPosition()
// Add scroll listener for hint visibility
$scrollAreaElement.addEventListener("scroll", initialCheckScrollPosition, { passive: true })
return () => {
elementsToListen.forEach((el) => {
el.removeEventListener("wheel", handleWheel)
})
$scrollAreaElement.removeEventListener("scroll", initialCheckScrollPosition)
}
}, [$scrollAreaElement, handleWheel])
return {
showScrollHint,
}
}

View File

@ -7,12 +7,8 @@ import { createAtomHooks } from "~/lib/jotai"
export const [, , useEntryTitleMeta, , getEntryTitleMeta, setEntryTitleMeta] = createAtomHooks(
atom(
null as Nullable<{
entryTitle: string
feedTitle: string
// id-set
feedId: string
entryId: string
title: string
description: string
}>,
),
)

View File

@ -1,7 +1,8 @@
import { useEntry } from "@follow/store/entry/hooks"
import { useEntry, useEntryReadHistory } from "@follow/store/entry/hooks"
import { useFeedById } from "@follow/store/feed/hooks"
import { useInboxById } from "@follow/store/inbox/hooks"
import { useEntryTranslation } from "@follow/store/translation/hooks"
import { useWhoami } from "@follow/store/user/hooks"
import { formatEstimatedMins, formatTimeToSeconds } from "@follow/utils"
import { titleCase } from "title-case"
import { useShallow } from "zustand/shallow"
@ -10,7 +11,6 @@ import { useShowAITranslation } from "~/atoms/ai-translation"
import { useActionLanguage } from "~/atoms/settings/general"
import { useUISettingKey } from "~/atoms/settings/ui"
import { RelativeTime } from "~/components/ui/datetime"
import { useFeature } from "~/hooks/biz/useFeature"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useFeedSafeUrl } from "~/hooks/common/useFeedSafeUrl"
import type { FeedIconEntry } from "~/modules/feed/feed-icon"
@ -18,7 +18,6 @@ import { FeedIcon } from "~/modules/feed/feed-icon"
import { getPreferredTitle } from "~/store/feed/hooks"
import { EntryTranslation } from "../../entry-column/translation"
import { EntryReadHistory } from "./entry-read-history"
interface EntryLinkProps {
entryId: string
@ -26,6 +25,7 @@ interface EntryLinkProps {
}
export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
const user = useWhoami()
const entry = useEntry(
entryId,
useShallow((state) => {
@ -58,11 +58,10 @@ export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
}),
)
const aiEnabled = useFeature("ai")
const hideRecentReader = useUISettingKey("hideRecentReader")
const feed = useFeedById(entry?.feedId)
const inbox = useInboxById(entry?.inboxId)
const data = useEntryReadHistory(entryId)
const entryHistory = data?.entryReadHistories
const populatedFullHref = useFeedSafeUrl(entryId)
const enableTranslation = useShowAITranslation()
const actionLanguage = useActionLanguage()
@ -76,6 +75,8 @@ export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
const navigateEntry = useNavigateEntry()
const hideRecentReader = useUISettingKey("hideRecentReader")
if (!entry) return null
return compact ? (
@ -93,16 +94,6 @@ export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
) : (
<div className="group relative block min-w-0 rounded-lg">
<div className="flex flex-col gap-3">
{/* Recent Readers */}
{aiEnabled &&
(hideRecentReader ? (
<div className="h-8" />
) : (
<div className="-mb-2 mt-2 flex h-8 items-center">
<EntryReadHistory entryId={entryId} />
</div>
))}
<a
href={populatedFullHref ?? "#"}
target="_blank"
@ -164,6 +155,19 @@ export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
<span className="text-xs tabular-nums">{entry.estimatedMins}</span>
</div>
)}
{(() => {
const readCount =
(entryHistory?.readCount ?? 0) +
(entryHistory?.userIds?.every((id) => id !== user?.id) ? 1 : 0)
return readCount > 0 && !hideRecentReader ? (
<div className="flex items-center gap-1.5">
<i className="i-mgc-eye-2-cute-re text-base" />
<span className="text-xs tabular-nums">{readCount.toLocaleString()}</span>
</div>
) : null
})()}
</div>
</div>
</div>

View File

@ -1,4 +1,3 @@
import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focusable/hooks.js"
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"
@ -12,33 +11,26 @@ 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 { AnimatePresence, useAnimationControls } from "motion/react"
import { useAnimationControls } from "motion/react"
import * as React from "react"
import { memo, useEffect, useRef, useState } from "react"
import { useHotkeys } from "react-hotkeys-hook"
import { useEntryIsInReadability } from "~/atoms/readability"
import { useIsZenMode } from "~/atoms/settings/ui"
import { Focusable, FocusablePresets } from "~/components/common/Focusable"
import { Focusable } from "~/components/common/Focusable"
import { m } from "~/components/common/Motion"
import { useInPeekModal } from "~/components/ui/modal/inspire/InPeekModal"
import { HotkeyScope } from "~/constants"
import { useFeature } from "~/hooks/biz/useFeature"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useFeedSafeUrl } from "~/hooks/common/useFeedSafeUrl"
import { useBlockActions } from "~/modules/ai-chat/store/hooks"
import { BlockSliceAction } from "~/modules/ai-chat/store/slices/block.slice"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { useCommandHotkey } from "~/modules/command/hooks/use-register-hotkey"
import { useWheelGestureClose } from "~/modules/entry-column/hooks/useWheelGestureClose"
import { ApplyEntryActions } from "../../ApplyEntryActions"
import { NAVIGATION_HINTS_ICONS, NAVIGATION_HINTS_TEXT } from "../../constants/navigation-hints"
import { useEntryContent } from "../../hooks"
import { useEntryNavigationHints } from "../../hooks/useEntryNavigationHints"
import { AIEntryHeader } from "../entry-header"
import { EntryTimeline } from "../EntryTimelineSidebar"
import { getEntryContentLayout } from "../layouts"
@ -139,7 +131,6 @@ const EntryContentImpl: Component<EntryContentProps> = ({
</RootPortal>
<EntryTimeline entryId={entryId} className="top-48" />
<EntryScrollArea scrollerRef={scrollerRef}>
<EntryNavigationHandler entryId={entryId} />
{/* Indicator for the entry */}
{!isZenMode && isInHasTimelineView && (
<>
@ -254,111 +245,3 @@ const AdaptiveContentRenderer: React.FC<{
return <LayoutComponent entryId={entryId} compact={compact} noMedia={noMedia} />
}
const EntryNavigationHandler = ({ entryId }: { entryId: string }) => {
const navigate = useNavigateEntry()
const when = useGlobalFocusableScopeSelector(FocusablePresets.isEntryRender)
// Handle close gesture
const handleCloseEntry = React.useCallback(() => {
navigate({ entryId: null })
}, [navigate])
// Enable wheel gesture to close entry when focused on entry render
const { showScrollHint } = useWheelGestureClose({
enabled: when,
onClose: handleCloseEntry,
})
// Navigation hints for entry content
const {
showFirstEntryHint,
showScrollHint: showScrollThresholdHint,
showBottomHint,
} = useEntryNavigationHints({
enabled: when && !!entryId,
entryId,
})
const isZenMode = useIsZenMode()
// TODO: Here, do not rely on the AI switch, but should be deps on the new layout.
const isAiEnabled = useFeature("ai")
const useBackHandler = isZenMode || isAiEnabled
useCommandHotkey({
commandId: COMMAND_ID.layout.focusToTimeline,
when: when && !useBackHandler,
shortcut: "Backspace, Escape",
})
const navigateToTimeline = useNavigateEntry()
useHotkeys(
"Escape",
() => {
navigateToTimeline({ entryId: null })
},
{ enabled: when && useBackHandler },
)
// Render hint button with different states
const renderHintButton = (icon: string, text: string, position: "top" | "bottom" = "top") => (
<m.div
initial={{ y: position === "top" ? -50 : 50 }}
animate={{ y: 0 }}
exit={{ y: position === "top" ? -50 : 50 }}
transition={Spring.presets.smooth}
className={clsx(
"pointer-events-none absolute z-40 flex justify-center",
position === "top" ? "inset-x-0 top-4" : "inset-x-0 bottom-24",
)}
>
<m.button
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={handleCloseEntry}
type="button"
className={clsx(
"group pointer-events-auto flex items-center gap-2",
"rounded-full border px-3.5 py-2",
"border-border/40 bg-material-ultra-thick 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]",
"backdrop-blur-background",
)}
>
<i className={clsx(icon, "text-text/90 mr-1 size-5")} />
<span className="text-text/90 text-left text-[13px] font-medium">{text}</span>
</m.button>
</m.div>
)
return (
<AnimatePresence mode="popLayout">
{/* First entry hint */}
{showFirstEntryHint &&
renderHintButton(
NAVIGATION_HINTS_ICONS.ARROW_UP,
NAVIGATION_HINTS_TEXT.SCROLL_UP_EXIT,
"top",
)}
{/* Scroll threshold hint or wheel gesture hint */}
{(showScrollThresholdHint || showScrollHint) &&
renderHintButton(
NAVIGATION_HINTS_ICONS.ARROW_LEFT_UP,
NAVIGATION_HINTS_TEXT.SCROLL_UP_EXIT,
"top",
)}
{/* Bottom hint */}
{showBottomHint &&
renderHintButton(
NAVIGATION_HINTS_ICONS.ARROW_TO_DOWN,
NAVIGATION_HINTS_TEXT.ESC_EXIT,
"bottom",
)}
</AnimatePresence>
)
}

View File

@ -1,4 +1,3 @@
import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focusable/hooks.js"
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js"
import { Spring } from "@follow/components/constants/spring.js"
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
@ -16,18 +15,17 @@ import { EventBus } from "@follow/utils/event-bus"
import { clsx, cn } from "@follow/utils/utils"
import { ErrorBoundary } from "@sentry/react"
import type { JSAnimation, Variants } from "motion/react"
import { AnimatePresence, m, useAnimationControls } from "motion/react"
import { m, useAnimationControls } from "motion/react"
import * as React from "react"
import { memo, useEffect, useMemo, useRef, useState } from "react"
import { useEntryIsInReadability } from "~/atoms/readability"
import { useIsZenMode, useUISettingKey } from "~/atoms/settings/ui"
import { Focusable, FocusablePresets } from "~/components/common/Focusable"
import { Focusable } from "~/components/common/Focusable"
import { ShadowDOM } from "~/components/common/ShadowDOM"
import type { TocRef } from "~/components/ui/markdown/components/Toc"
import { useInPeekModal } from "~/components/ui/modal/inspire/InPeekModal"
import { HotkeyScope } from "~/constants"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useFeedSafeUrl } from "~/hooks/common/useFeedSafeUrl"
@ -36,9 +34,7 @@ import { EntryContentHTMLRenderer } from "~/modules/renderer/html"
import { WrappedElementProvider } from "~/providers/wrapped-element-provider"
import { ApplyEntryActions } from "../../ApplyEntryActions"
import { NAVIGATION_HINTS_ICONS, NAVIGATION_HINTS_TEXT } from "../../constants/navigation-hints"
import { useEntryContent, useEntryMediaInfo } from "../../hooks"
import { useEntryNavigationHints } from "../../hooks/useEntryNavigationHints"
import { EntryHeader } from "../entry-header"
import { EntryAttachments } from "../EntryAttachments"
import { EntryTimelineSidebar } from "../EntryTimelineSidebar"
@ -141,7 +137,6 @@ const EntryContentImpl: Component<EntryContentProps> = ({
</RootPortal>
<EntryTimelineSidebar entryId={entryId} />
<EntryScrollArea className={className} scrollerRef={scrollerRef}>
<EntryNavigationHandler entryId={entryId} />
{/* Indicator for the entry */}
<m.div
initial={pageMotionVariants.initial}
@ -314,73 +309,3 @@ const Renderer: React.FC<{
</EntryContentHTMLRenderer>
)
})
// EntryNavigationHandler for legacy version (without wheel gesture)
const EntryNavigationHandler = ({ entryId }: { entryId: string }) => {
const navigate = useNavigateEntry()
const when = useGlobalFocusableScopeSelector(FocusablePresets.isEntryRender)
// Handle close gesture
const handleCloseEntry = React.useCallback(() => {
navigate({ entryId: null })
}, [navigate])
// Navigation hints for entry content (legacy version without wheel gesture)
const { showFirstEntryHint, showScrollHint, showBottomHint } = useEntryNavigationHints({
enabled: when && !!entryId,
entryId,
})
// Render hint button with different states
const renderHintButton = (icon: string, text: string, position: "top" | "bottom" = "top") => (
<m.div
initial={{ y: position === "top" ? -50 : 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: position === "top" ? -50 : 50, opacity: 0 }}
transition={Spring.presets.smooth}
className={clsx(
"pointer-events-none absolute z-40 flex justify-center",
position === "top" ? "inset-x-0 top-4" : "inset-x-0 bottom-4",
)}
>
<button
onClick={handleCloseEntry}
type="button"
className={clsx(
"group pointer-events-auto flex items-center gap-2",
"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]",
"backdrop-blur-background",
)}
>
<i className={clsx(icon, "text-text/90 mr-1 size-5")} />
<span className="text-text/90 text-left text-[13px] font-medium">{text}</span>
</button>
</m.div>
)
return (
<AnimatePresence mode="popLayout">
{/* First entry hint */}
{showFirstEntryHint &&
renderHintButton(
NAVIGATION_HINTS_ICONS.ARROW_UP,
NAVIGATION_HINTS_TEXT.SCROLL_UP_EXIT,
"top",
)}
{/* Scroll threshold hint */}
{showScrollHint &&
renderHintButton(
NAVIGATION_HINTS_ICONS.ARROW_UP,
NAVIGATION_HINTS_TEXT.SCROLL_UP_EXIT,
"top",
)}
{/* Bottom hint */}
{showBottomHint &&
renderHintButton(NAVIGATION_HINTS_ICONS.CLOSE, NAVIGATION_HINTS_TEXT.ESC_EXIT, "bottom")}
</AnimatePresence>
)
}

View File

@ -30,11 +30,11 @@ export const EntryTitleMetaHandler: Component<{
useEffect(() => {
if (entry?.title && feedTitle) {
setEntryTitleMeta({ entryTitle: entry.title, feedTitle, feedId: entry.feedId!, entryId })
setEntryTitleMeta({ title: entry.title, description: feedTitle })
}
return () => {
setEntryTitleMeta(null)
}
}, [entryId, entry?.title, feedTitle, entry?.feedId])
}, [entryId, entry?.title, feedTitle])
return null
}

View File

@ -2,17 +2,19 @@ import { memo } from "react"
import { EntryHeaderRoot } from "./internal/context"
import { EntryHeaderActionsContainer } from "./internal/EntryHeaderActionsContainer"
import { EntryHeaderBreadcrumb } from "./internal/EntryHeaderBreadcrumb"
import { EntryHeaderMeta } from "./internal/EntryHeaderMeta"
import { EntryHeaderReadHistory } from "./internal/EntryHeaderReadHistory"
import type { EntryHeaderProps } from "./types"
function EntryHeaderImpl({ entryId, className, compact }: EntryHeaderProps) {
return (
<EntryHeaderRoot entryId={entryId} className={className} compact={compact}>
<EntryHeaderReadHistory />
<div
className="relative z-10 flex w-full items-center justify-between gap-3"
data-hide-in-print
>
<EntryHeaderBreadcrumb />
<EntryHeaderMeta />
<EntryHeaderActionsContainer />
</div>
</EntryHeaderRoot>

View File

@ -3,4 +3,5 @@ export * from "./EntryHeader"
export * from "./internal/context"
export * from "./internal/EntryHeaderActionsContainer"
export * from "./internal/EntryHeaderMeta"
export * from "./internal/EntryHeaderReadHistory"
export * from "./types"

View File

@ -1,294 +0,0 @@
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import { views } from "@follow/constants"
import { getEntry, getEntryIdsByFeedId } from "@follow/store/entry/getter"
import { useFeedById } from "@follow/store/feed/hooks"
import { useListById } from "@follow/store/list/hooks"
import {
getFeedSubscriptionByViewSelector,
getListSubscriptionByViewSelector,
} from "@follow/store/subscription/getter"
import type {
useFeedSubscriptionByView,
useListSubscriptionByView,
} from "@follow/store/subscription/hooks"
import { useSubscriptionStore } from "@follow/store/subscription/store"
import { cn } from "@follow/utils/utils"
import { useForceUpdate } from "motion/react"
import { useCallback, useRef } from "react"
import { useTranslation } from "react-i18next"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu/dropdown-menu"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { getRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { getPreferredTitle } from "~/store/feed/hooks"
import { useEntryTitleMeta } from "../../../atoms"
import { useEntryHeaderContext } from "./context"
const Slash = (
<i className="i-mingcute-line-line text-text-tertiary size-4 shrink-0 rotate-[-25deg]" />
)
function ViewSubscriptionsDropdown({
view,
onNavigate,
}: {
view: number
onNavigate: ReturnType<typeof useNavigateEntry>
}) {
const feedSubsRef = useRef<ReturnType<typeof useFeedSubscriptionByView>>([])
const listSubsRef = useRef<ReturnType<typeof useListSubscriptionByView>>([])
const [forceUpdate] = useForceUpdate()
const handleRefreshDropDownData = useCallback(
(open: boolean) => {
if (!open) return
// Get fresh data from store
const state = useSubscriptionStore.getState()
const feedSubs = getFeedSubscriptionByViewSelector(state)(view)
const listSubs = getListSubscriptionByViewSelector(state)(view)
feedSubsRef.current = feedSubs || []
listSubsRef.current = listSubs || []
forceUpdate()
},
[view, forceUpdate],
)
const routeParams = getRouteParams()
const { isAllFeeds, listId, feedId } = routeParams
// Check if there's any subscription data for this view (without causing re-render)
// This allows initial render to show the dropdown if subscriptions exist
const state = useSubscriptionStore.getState()
const initialFeedSubs = getFeedSubscriptionByViewSelector(state)(view)
const initialListSubs = getListSubscriptionByViewSelector(state)(view)
const hasAnyInitial = (initialFeedSubs?.length ?? 0) + (initialListSubs?.length ?? 0) > 0
if (!hasAnyInitial) return null
return (
<DropdownMenu onOpenChange={handleRefreshDropDownData}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="text-text-tertiary hover:text-text focus-visible:bg-fill/60 -ml-1 inline-flex size-6 items-center justify-center rounded transition-colors"
aria-label="Open subscriptions of this view"
>
<i className="i-mingcute-down-line size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-0">
<ScrollArea.ScrollArea
flex
rootClassName="max-h-[60vh] min-h-0 relative min-w-64"
viewportClassName="max-h-[60vh]"
>
<div className="p-1">
<DropdownMenuItem
onClick={() => onNavigate({ entryId: null, view })}
checked={isAllFeeds}
>
<span className="truncate">All</span>
</DropdownMenuItem>
{listSubsRef.current && listSubsRef.current.length > 0 && (
<div className="text-text-tertiary px-2 py-1 text-xs">Lists</div>
)}
{listSubsRef.current?.map((s) =>
s.listId ? (
<DropdownMenuItem
checked={s.listId === listId}
key={`list-${s.listId}`}
onClick={() => s.listId && onNavigate({ entryId: null, listId: s.listId })}
>
<ListNameItem listId={s.listId} />
</DropdownMenuItem>
) : null,
)}
{feedSubsRef.current && feedSubsRef.current.length > 0 && (
<div className="text-text-tertiary px-2 py-1 text-xs">Feeds</div>
)}
{feedSubsRef.current?.map((s) =>
s.feedId ? (
<DropdownMenuItem
checked={s.feedId === feedId}
key={`feed-${s.feedId}`}
onClick={() => s.feedId && onNavigate({ entryId: null, feedId: s.feedId })}
>
<FeedNameItem feedId={s.feedId} />
</DropdownMenuItem>
) : null,
)}
</div>
</ScrollArea.ScrollArea>
</DropdownMenuContent>
</DropdownMenu>
)
}
const ListNameItem = ({ listId }: { listId: string }) => {
const name = useListById(listId, (s) => s?.title)
if (!name) return null
return <span className="truncate">{name}</span>
}
const FeedNameItem = ({ feedId }: { feedId: string }) => {
const feed = useFeedById(feedId)
if (!feed) return null
return <span className="truncate">{getPreferredTitle(feed)}</span>
}
function FeedEntriesDropdown({
feedId,
currentEntryId,
onNavigate,
}: {
feedId: string
currentEntryId: string
onNavigate: ReturnType<typeof useNavigateEntry>
}) {
const siblingEntriesRef = useRef<{ id: string; title: string }[]>([])
const [forceUpdate] = useForceUpdate()
const handleRefreshDropDownData = useCallback(
(open: boolean) => {
if (!open) return
const entryIds = getEntryIdsByFeedId(feedId)
if (!entryIds) return
siblingEntriesRef.current = []
for (const entryId of entryIds) {
const entry = getEntry(entryId)
if (!entry) continue
const { title } = entry
if (!title) continue
siblingEntriesRef.current.push({ id: entryId, title })
}
forceUpdate()
},
[feedId, forceUpdate],
)
// Check if there are any entries for this feed
const entryIds = getEntryIdsByFeedId(feedId)
if (!entryIds || entryIds.length <= 1) return null
return (
<DropdownMenu onOpenChange={handleRefreshDropDownData}>
<DropdownMenuTrigger asChild>
<button
type="button"
className="text-text-tertiary hover:text-text focus-visible:bg-fill/60 -ml-2 inline-flex size-6 items-center justify-center rounded transition-colors"
aria-label="Open entries from this feed"
>
<i className="i-mingcute-down-line size-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="p-0">
<ScrollArea.ScrollArea
rootClassName="max-h-[60vh] min-w-64"
viewportClassName="max-h-[60vh]"
>
<div className="p-1">
{siblingEntriesRef.current.map((e) => (
<DropdownMenuItem
key={e.id}
onClick={() => onNavigate({ entryId: e.id })}
checked={e.id === currentEntryId}
>
<span className="truncate" title={e.title}>
{e.title}
</span>
</DropdownMenuItem>
))}
</div>
</ScrollArea.ScrollArea>
</DropdownMenuContent>
</DropdownMenu>
)
}
export function EntryHeaderBreadcrumb() {
const meta = useEntryTitleMeta()
const navigate = useNavigateEntry()
const { entryId } = useEntryHeaderContext()
const { t } = useTranslation()
const view = useRouteParamsSelector((s) => s.view)
if (!meta) return null
return (
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-hidden">
<nav
aria-label="Breadcrumb"
className={
"text-text-secondary group/breadcrumb flex min-w-0 items-center gap-1 truncate leading-tight"
}
>
<div className="flex min-w-0 items-center gap-1">
{/* Return Back Button */}
<button
type="button"
className="text-text-secondary hover:text-text hover:bg-fill/50 focus-visible:bg-fill/60 inline-flex max-w-[40vw] shrink-0 items-center truncate rounded bg-transparent px-1.5 py-0.5 text-sm transition-colors"
onClick={() => navigate({ entryId: null })}
>
<i className="i-mingcute-arrow-left-line size-4" />
</button>
{views[view]?.name && (
<div className="flex items-center">
<button
type="button"
className={cn(
"text-text-secondary hover:text-text hover:bg-fill/50 focus-visible:bg-fill/60 inline-flex max-w-[40vw] items-center truncate rounded bg-transparent px-1.5 py-0.5 text-sm transition-colors",
)}
onClick={() => navigate({ entryId: null, view })}
>
<span className="text-text-secondary text-sm">
{t(views[view]?.name, { ns: "common" })}
</span>
</button>
<ViewSubscriptionsDropdown view={view} onNavigate={navigate} />
</div>
)}
{Slash}
<div className="flex items-center">
<button
type="button"
className={cn(
"text-text-secondary hover:text-text hover:bg-fill/50 focus-visible:bg-fill/60 inline-flex max-w-[40vw] items-center truncate rounded bg-transparent px-1.5 py-0.5 text-sm transition-colors",
)}
onClick={() => navigate({ entryId: null, feedId: meta.feedId })}
title={meta.feedTitle}
>
<span className="truncate">{meta.feedTitle}</span>
</button>
<FeedEntriesDropdown
feedId={meta.feedId}
currentEntryId={entryId}
onNavigate={navigate}
/>
</div>
{Slash}
<span className="text-text truncate px-1.5 py-0.5 text-sm" title={meta.entryTitle}>
{meta.entryTitle}
</span>
</div>
</nav>
</div>
)
}

View File

@ -6,7 +6,7 @@ import { useEntryContentScrollToTop, useEntryTitleMeta } from "../../../atoms"
function EntryHeaderMetaImpl() {
const entryTitleMeta = useEntryTitleMeta()
const isAtTop = useEntryContentScrollToTop()
const shouldShowMeta = !isAtTop && !!entryTitleMeta?.entryTitle
const shouldShowMeta = !isAtTop && !!entryTitleMeta?.title
return (
<div className="flex min-w-0 shrink grow">
<AnimatePresence>
@ -17,10 +17,10 @@ function EntryHeaderMetaImpl() {
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.entryTitle}</span>
<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.feedTitle}
{entryTitleMeta.description}
</span>
</m.div>
)}

View File

@ -1,40 +0,0 @@
/**
* Constants for entry navigation hints behavior
*/
export const NAVIGATION_HINTS_CONSTANTS = {
/** Default scroll threshold to trigger scroll hint (px) */
DEFAULT_SCROLL_THRESHOLD: 100,
/** Delay before showing first entry hint (ms) */
FIRST_HINT_DELAY: 500,
/** Duration to show hints before auto-hiding (ms) */
HINT_DISPLAY_DURATION: 3000,
/** Distance from bottom to trigger bottom hint (px) */
BOTTOM_THRESHOLD: 50,
/** Distance from bottom to hide bottom hint when scrolling up (px) */
BOTTOM_HIDE_THRESHOLD: 100,
/** Throttle interval for scroll handler (ms) */
SCROLL_THROTTLE_INTERVAL: 100,
} as const
/**
* Text constants for navigation hints
*/
export const NAVIGATION_HINTS_TEXT = {
SCROLL_UP_EXIT: "Scroll up or click left-top back button to exit",
ESC_EXIT: "Press ESC or click left-top back button to exit",
} as const
/**
* Icon constants for navigation hints
*/
export const NAVIGATION_HINTS_ICONS = {
ARROW_UP: "i-mgc-up-cute-re",
ARROW_LEFT_UP: "i-mingcute-arrow-left-up-line",
ARROW_TO_DOWN: "i-mingcute-arrow-to-down-line",
CLOSE: "i-mgc-close-cute-re",
} as const

View File

@ -1,201 +0,0 @@
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
import { throttle } from "es-toolkit"
import { startTransition, useEffect, useRef, useState } from "react"
import { useEventCallback } from "usehooks-ts"
import { NAVIGATION_HINTS_CONSTANTS } from "../constants/navigation-hints"
interface UseEntryNavigationHintsOptions {
/** Whether hints are enabled */
enabled: boolean
/** Entry ID to track changes */
entryId?: string
/** Scroll threshold to show hint */
scrollThreshold?: number
}
interface UseEntryNavigationHintsReturn {
/** Show hint for first entry */
showFirstEntryHint: boolean
/** Show hint when scrolled past threshold */
showScrollHint: boolean
/** Show hint when at bottom */
showBottomHint: boolean
}
/**
* Custom hook for managing entry navigation hints
* Shows contextual hints based on scroll position and entry state
*/
export const useEntryNavigationHints = ({
enabled,
entryId,
scrollThreshold = NAVIGATION_HINTS_CONSTANTS.DEFAULT_SCROLL_THRESHOLD,
}: UseEntryNavigationHintsOptions): UseEntryNavigationHintsReturn => {
const $scrollElement = useScrollViewElement()
// State for different hint types
const [showFirstEntryHint, setShowFirstEntryHint] = useState(false)
const [showScrollHint, setShowScrollHint] = useState(false)
const [showBottomHint, setShowBottomHint] = useState(false)
// Refs to track state
const hasShownFirstHintRef = useRef(false)
const hasShownScrollHintRef = useRef(false)
const hasShownBottomHintRef = useRef(false)
const currentEntryIdRef = useRef<string>(void 0)
const firstHintTimerRef = useRef<ReturnType<typeof setTimeout>>(void 0)
const scrollHintTimerRef = useRef<ReturnType<typeof setTimeout>>(void 0)
const bottomHintTimerRef = useRef<ReturnType<typeof setTimeout>>(void 0)
const lastScrollTopRef = useRef(0)
const scrollDirectionRef = useRef<"up" | "down" | "none">("none")
// Reset hints when entry changes
useEffect(() => {
if (entryId && entryId !== currentEntryIdRef.current) {
currentEntryIdRef.current = entryId
hasShownFirstHintRef.current = false
hasShownScrollHintRef.current = false
hasShownBottomHintRef.current = false
lastScrollTopRef.current = 0
scrollDirectionRef.current = "none"
// Clear existing timers
if (firstHintTimerRef.current) clearTimeout(firstHintTimerRef.current)
if (scrollHintTimerRef.current) clearTimeout(scrollHintTimerRef.current)
if (bottomHintTimerRef.current) clearTimeout(bottomHintTimerRef.current)
// Reset all hint states with low priority
startTransition(() => {
setShowFirstEntryHint(false)
setShowScrollHint(false)
setShowBottomHint(false)
})
if (enabled) {
// Show first entry hint after a brief delay
firstHintTimerRef.current = setTimeout(() => {
if (!hasShownFirstHintRef.current) {
startTransition(() => {
setShowFirstEntryHint(true)
})
hasShownFirstHintRef.current = true
// Hide after configured duration
firstHintTimerRef.current = setTimeout(() => {
startTransition(() => {
setShowFirstEntryHint(false)
})
}, NAVIGATION_HINTS_CONSTANTS.HINT_DISPLAY_DURATION)
}
}, NAVIGATION_HINTS_CONSTANTS.FIRST_HINT_DELAY) // Small delay to allow content to load
}
}
}, [entryId, enabled])
// Scroll handler to manage hints based on scroll position
const handleScroll = useEventCallback(
throttle(() => {
if (!enabled || !$scrollElement) return
const { scrollTop } = $scrollElement
const { scrollHeight } = $scrollElement
const { clientHeight } = $scrollElement
const scrollBottom = scrollHeight - clientHeight - scrollTop
// Detect scroll direction
const lastScrollTop = lastScrollTopRef.current
if (scrollTop > lastScrollTop) {
scrollDirectionRef.current = "down"
} else if (scrollTop < lastScrollTop) {
scrollDirectionRef.current = "up"
}
lastScrollTopRef.current = scrollTop
// Check if scrolled past threshold and scrolling up
if (
scrollTop > scrollThreshold &&
!hasShownScrollHintRef.current &&
scrollDirectionRef.current === "up"
) {
hasShownScrollHintRef.current = true
startTransition(() => {
setShowScrollHint(true)
})
// Clear previous timer
if (scrollHintTimerRef.current) clearTimeout(scrollHintTimerRef.current)
// Hide after configured duration
scrollHintTimerRef.current = setTimeout(() => {
startTransition(() => {
setShowScrollHint(false)
})
}, NAVIGATION_HINTS_CONSTANTS.HINT_DISPLAY_DURATION)
}
// Check if at bottom (within configured threshold)
if (
scrollBottom <= NAVIGATION_HINTS_CONSTANTS.BOTTOM_THRESHOLD &&
!hasShownBottomHintRef.current
) {
hasShownBottomHintRef.current = true
startTransition(() => {
setShowBottomHint(true)
})
// Clear previous timer
if (bottomHintTimerRef.current) clearTimeout(bottomHintTimerRef.current)
// Hide after configured duration
bottomHintTimerRef.current = setTimeout(() => {
startTransition(() => {
setShowBottomHint(false)
})
hasShownBottomHintRef.current = false
}, NAVIGATION_HINTS_CONSTANTS.HINT_DISPLAY_DURATION)
}
// Hide bottom hint if user scrolls up from bottom
if (
scrollBottom > NAVIGATION_HINTS_CONSTANTS.BOTTOM_HIDE_THRESHOLD &&
hasShownBottomHintRef.current &&
scrollDirectionRef.current === "up"
) {
// Clear timer if exists
if (bottomHintTimerRef.current) clearTimeout(bottomHintTimerRef.current)
startTransition(() => {
setShowBottomHint(false)
})
hasShownBottomHintRef.current = false
}
}, NAVIGATION_HINTS_CONSTANTS.SCROLL_THROTTLE_INTERVAL),
)
// Attach scroll listener
useEffect(() => {
if (!enabled || !$scrollElement) return
$scrollElement.addEventListener("scroll", handleScroll, { passive: true })
return () => {
$scrollElement.removeEventListener("scroll", handleScroll)
}
}, [enabled, $scrollElement, handleScroll])
// Cleanup timers on unmount
useEffect(() => {
return () => {
if (firstHintTimerRef.current) clearTimeout(firstHintTimerRef.current)
if (scrollHintTimerRef.current) clearTimeout(scrollHintTimerRef.current)
if (bottomHintTimerRef.current) clearTimeout(bottomHintTimerRef.current)
}
}, [])
return {
showFirstEntryHint,
showScrollHint,
showBottomHint,
}
}

View File

@ -14,8 +14,9 @@ const microDampingPreset: Transition = {
const microReboundPreset: Transition = {
type: "spring",
stiffness: 300,
damping: 20,
stiffness: 400,
damping: 30,
duration: 0.3,
}
const softSpringPreset: Transition = {