From f3a4eab8af04fbc8c83dae9bf781a79c64b8b17b Mon Sep 17 00:00:00 2001 From: DIYgod Date: Sun, 17 Aug 2025 12:54:54 +0800 Subject: [PATCH] feat: revert entry animation and entry header --- .../modules/entry-column/AIEntryLayout.tsx | 124 +++++++- .../hooks/useWheelGestureClose.ts | 100 ------ .../src/modules/entry-content/atoms.tsx | 8 +- .../entry-content/components/EntryTitle.tsx | 36 ++- .../entry-content/EntryContent.ai.tsx | 123 +------- .../entry-content/EntryContent.legacy.tsx | 79 +---- .../entry-content/EntryTitleMetaHandler.tsx | 4 +- .../components/entry-header/AIEntryHeader.tsx | 6 +- .../components/entry-header/index.ts | 1 + .../internal/EntryHeaderBreadcrumb.tsx | 294 ------------------ .../entry-header/internal/EntryHeaderMeta.tsx | 6 +- .../constants/navigation-hints.ts | 40 --- .../hooks/useEntryNavigationHints.ts | 201 ------------ .../components/src/constants/spring.ts | 5 +- 14 files changed, 156 insertions(+), 871 deletions(-) delete mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/hooks/useWheelGestureClose.ts delete mode 100644 apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderBreadcrumb.tsx delete mode 100644 apps/desktop/layer/renderer/src/modules/entry-content/constants/navigation-hints.ts delete mode 100644 apps/desktop/layer/renderer/src/modules/entry-content/hooks/useEntryNavigationHints.ts diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/AIEntryLayout.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/AIEntryLayout.tsx index 69a5a22c0..d0d0664a6 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/AIEntryLayout.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/AIEntryLayout.tsx @@ -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(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 = () => { {/* Entry content overlay with exit animation */} - + {realEntryId && ( - + {/* Scroll hint indicator */} +
+ +
+
)}
diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useWheelGestureClose.ts b/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useWheelGestureClose.ts deleted file mode 100644 index 500b983e4..000000000 --- a/apps/desktop/layer/renderer/src/modules/entry-column/hooks/useWheelGestureClose.ts +++ /dev/null @@ -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, - } -} diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/atoms.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/atoms.tsx index 041154ede..b7ce292d0 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/atoms.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/atoms.tsx @@ -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 }>, ), ) diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryTitle.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryTitle.tsx index f378b5bf8..f165eed76 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryTitle.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryTitle.tsx @@ -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) => { ) : ( diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.ai.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.ai.tsx index 02c5a22ba..e0fd158c1 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.ai.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.ai.tsx @@ -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 = ({ - {/* Indicator for the entry */} {!isZenMode && isInHasTimelineView && ( <> @@ -254,111 +245,3 @@ const AdaptiveContentRenderer: React.FC<{ return } - -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") => ( - - - - {text} - - - ) - - return ( - - {/* 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", - )} - - ) -} diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.legacy.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.legacy.tsx index 6c8bc16c0..53fb304af 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.legacy.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.legacy.tsx @@ -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 = ({ - {/* Indicator for the entry */} ) }) - -// 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") => ( - - - - ) - - return ( - - {/* 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")} - - ) -} diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryTitleMetaHandler.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryTitleMetaHandler.tsx index 357b3bb4b..cfe2294c3 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryTitleMetaHandler.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryTitleMetaHandler.tsx @@ -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 } diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/AIEntryHeader.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/AIEntryHeader.tsx index 4d9cf364a..3019bb904 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/AIEntryHeader.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/AIEntryHeader.tsx @@ -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 ( +
- +
diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/index.ts b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/index.ts index 9bc1c6580..ec16514a5 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/index.ts +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/index.ts @@ -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" diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderBreadcrumb.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderBreadcrumb.tsx deleted file mode 100644 index 4f6eef2b4..000000000 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderBreadcrumb.tsx +++ /dev/null @@ -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 = ( - -) - -function ViewSubscriptionsDropdown({ - view, - onNavigate, -}: { - view: number - onNavigate: ReturnType -}) { - const feedSubsRef = useRef>([]) - const listSubsRef = useRef>([]) - 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 ( - - - - - - -
- onNavigate({ entryId: null, view })} - checked={isAllFeeds} - > - All - - {listSubsRef.current && listSubsRef.current.length > 0 && ( -
Lists
- )} - {listSubsRef.current?.map((s) => - s.listId ? ( - s.listId && onNavigate({ entryId: null, listId: s.listId })} - > - - - ) : null, - )} - {feedSubsRef.current && feedSubsRef.current.length > 0 && ( -
Feeds
- )} - {feedSubsRef.current?.map((s) => - s.feedId ? ( - s.feedId && onNavigate({ entryId: null, feedId: s.feedId })} - > - - - ) : null, - )} -
-
-
-
- ) -} - -const ListNameItem = ({ listId }: { listId: string }) => { - const name = useListById(listId, (s) => s?.title) - if (!name) return null - return {name} -} - -const FeedNameItem = ({ feedId }: { feedId: string }) => { - const feed = useFeedById(feedId) - - if (!feed) return null - return {getPreferredTitle(feed)} -} - -function FeedEntriesDropdown({ - feedId, - currentEntryId, - onNavigate, -}: { - feedId: string - currentEntryId: string - onNavigate: ReturnType -}) { - 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 ( - - - - - - -
- {siblingEntriesRef.current.map((e) => ( - onNavigate({ entryId: e.id })} - checked={e.id === currentEntryId} - > - - {e.title} - - - ))} -
-
-
-
- ) -} - -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 ( -
- -
- ) -} diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderMeta.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderMeta.tsx index 7f86d45e9..2abfe97ac 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderMeta.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderMeta.tsx @@ -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 (
@@ -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" > - {entryTitleMeta.entryTitle} + {entryTitleMeta.title} - {entryTitleMeta.feedTitle} + {entryTitleMeta.description} )} diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/constants/navigation-hints.ts b/apps/desktop/layer/renderer/src/modules/entry-content/constants/navigation-hints.ts deleted file mode 100644 index 3264f4c5b..000000000 --- a/apps/desktop/layer/renderer/src/modules/entry-content/constants/navigation-hints.ts +++ /dev/null @@ -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 diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/hooks/useEntryNavigationHints.ts b/apps/desktop/layer/renderer/src/modules/entry-content/hooks/useEntryNavigationHints.ts deleted file mode 100644 index 7fd3e7483..000000000 --- a/apps/desktop/layer/renderer/src/modules/entry-content/hooks/useEntryNavigationHints.ts +++ /dev/null @@ -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(void 0) - const firstHintTimerRef = useRef>(void 0) - const scrollHintTimerRef = useRef>(void 0) - const bottomHintTimerRef = useRef>(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, - } -} diff --git a/packages/internal/components/src/constants/spring.ts b/packages/internal/components/src/constants/spring.ts index db364b7c7..a358862e7 100644 --- a/packages/internal/components/src/constants/spring.ts +++ b/packages/internal/components/src/constants/spring.ts @@ -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 = {