diff --git a/apps/desktop/layer/renderer/src/atoms/debug-feature.ts b/apps/desktop/layer/renderer/src/atoms/debug-feature.ts index 0e80022ab..74bd5eb6f 100644 --- a/apps/desktop/layer/renderer/src/atoms/debug-feature.ts +++ b/apps/desktop/layer/renderer/src/atoms/debug-feature.ts @@ -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>(getStorageNS("debug-feature"), {})) + +export { useDebugFeatureValue as useDebugFeatures } diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useFeature.ts b/apps/desktop/layer/renderer/src/hooks/biz/useFeature.ts index aa73267b1..58e4580c7 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useFeature.ts +++ b/apps/desktop/layer/renderer/src/hooks/biz/useFeature.ts @@ -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 } diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/EntryLayoutContent.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/EntryLayoutContent.tsx index 9fedc0d12..12656ee09 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/EntryLayoutContent.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/EntryLayoutContent.tsx @@ -92,7 +92,7 @@ export const EntryLayoutContentWithAI = () => { export const EntryLayoutContent = () => { const aiEnabled = useFeature("ai") if (aiEnabled) { - return + return null } return } @@ -175,6 +175,6 @@ const EntryGridContainer: FC< ) } else { - return
{children}
+ return
{children}
} } diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/index.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/index.tsx index bc18a3854..ef640226b 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/entry-content/index.tsx @@ -1 +1 @@ -export { EntryLayoutContent as RightContentLayout } from "./EntryLayoutContent" +export { EntryLayoutContent } from "./EntryLayoutContent" diff --git a/apps/desktop/layer/renderer/src/modules/app/EnvironmentIndicator.tsx b/apps/desktop/layer/renderer/src/modules/app/EnvironmentIndicator.tsx index 324e38228..c5c4fb9d1 100644 --- a/apps/desktop/layer/renderer/src/modules/app/EnvironmentIndicator.tsx +++ b/apps/desktop/layer/renderer/src/modules/app/EnvironmentIndicator.tsx @@ -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 + const setDebugValues = useSetDebugFeatureValue() + + const overrideEnabled = !!debugValues.__override + + const handleToggleOverride = (checked: boolean) => { + setDebugValues((prev) => ({ ...(prev as Record), __override: checked })) + } + + const handleToggleFeature = (key: string, checked: boolean) => { + setDebugValues((prev) => ({ ...(prev as Record), [key]: checked })) + } + + const featureKeys = Object.keys(featureConfigMap) + + return ( +
+
+
+
Debug override features
+ +
+

+ When enabled, the switches below override server feature flags locally. +

+
+
+ {featureKeys.map((key) => ( +
+ {key} + handleToggleFeature(key, v)} + disabled={!overrideEnabled} + /> +
+ ))} +
+
+
+ +
+
Debug actions
+
+ {Object.entries(actionMap).map(([key, action]) => ( +
+ {key} + +
+ ))} +
+
+
+ ) +} + 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 ( -
- {Object.entries(actionMap).map(([key, action]) => { - return ( -
- {key} - -
- ) - })} -
- ) - }, + content: EnvironmentDebugModalContent, }) }} > 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 c7d244329..756b7f72f 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/AIEntryLayout.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/AIEntryLayout.tsx @@ -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(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 = () => { {/* Entry content overlay with exit animation */} - + {realEntryId && ( - {/* Scroll hint indicator */} -
- -
- +
)}
diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/item.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/item.tsx index 8c45f2359..649053ac5 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/item.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/item.tsx @@ -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 = memo(({ entryId, view }) => { - const entry = useEntry(entryId, () => ({})) + const hasEntry = useHasEntry(entryId) - if (!entry) return null + if (!hasEntry) return null return }) @@ -63,9 +63,9 @@ export const EntryVirtualListItem = ({ React.DetailedHTMLProps, HTMLDivElement> & { ref?: React.Ref }) => { - const entry = useEntry(entryId, () => ({})) + const hasEntry = useHasEntry(entryId) - if (!entry) return
+ if (!hasEntry) return
return (
diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/templates/grid-item-template.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/templates/grid-item-template.tsx index 3d41c89f1..50426dd73 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/templates/grid-item-template.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/templates/grid-item-template.tsx @@ -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 (
{children} diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryTimelineSidebar.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryTimelineSidebar.tsx index 0b320c8f8..34a99b345 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryTimelineSidebar.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/EntryTimelineSidebar.tsx @@ -17,15 +17,15 @@ export const EntryTimelineSidebar = ({ entryId }: { entryId: string }) => { return null } - return + return } -const Timeline = ({ entryId }: { entryId: string }) => { +export const EntryTimeline = ({ entryId }: { entryId: string }) => { const entryIds = useGetEntryIdInRange(entryId, [5, 5]) return ( 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 4b032bb57..14d28369f 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,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 = ({ entryId, noMedia, @@ -65,7 +72,6 @@ const EntryContentImpl: Component = ({ const scrollerRef = useRef(null) const safeUrl = useFeedSafeUrl(entryId) - const isInPeekModal = useInPeekModal() const isZenMode = useIsZenMode() const [panelPortalElement, setPanelPortalElement] = useState(null) @@ -89,18 +95,30 @@ const EntryContentImpl: Component = ({ 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 ( -
+ - {!isInPeekModal && ( - - )} +
= ({ scrollerRef={scrollerRef} /> - + {/* Indicator for the entry */}
- {!isZenMode && isInHasTimelineView && !isInPeekModal && ( + {!isZenMode && isInHasTimelineView && ( <> -
+
= ({
-
+
{ @@ -147,10 +165,7 @@ const EntryContentImpl: Component = ({
@@ -187,7 +202,7 @@ const EntryContentImpl: Component = ({ {/* {!isInPeekModal && } */} -
+ ) } export const EntryContent = memo(EntryContentImpl) diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryScrollingAndNavigationHandler.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryScrollingAndNavigationHandler.tsx index a1bec41f9..2cbfd8794 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryScrollingAndNavigationHandler.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryScrollingAndNavigationHandler.tsx @@ -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 ( {showKeepScrollingPanel && ( - - Already scrolled to the bottom. -
- Keep pressing to jump to the next article -
+ + + )}
) } - -const FloatPanel: React.FC<{ children: React.ReactNode; side: "bottom" | "top" }> = ({ - children, - side, -}) => ( - - {children} - -) 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 new file mode 100644 index 000000000..9e069c7c4 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/AIEntryHeader.tsx @@ -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 ( + + ) +} + +export const AIEntryHeader = memo(EntryHeaderImpl) diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/EntryHeader.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/EntryHeader.tsx index 488a003cf..3b405557b 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/EntryHeader.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/EntryHeader.tsx @@ -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 ( -
- {!hideRecentReader && ( -
- -
- )} -
-
- - {shouldShowMeta && ( - - - {entryTitleMeta.title} - - - - {entryTitleMeta.description} - - - )} - -
- - {!isWide && ( -
- - -
- )} -
-
+ ) } 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 ea1eaaa54..d9b65cd9e 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 @@ -1,2 +1,3 @@ +export * from "./AIEntryHeader" export * from "./EntryHeader" export * from "./types" diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/BaseEntryHeader.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/BaseEntryHeader.tsx new file mode 100644 index 000000000..323ac86dc --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/BaseEntryHeader.tsx @@ -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 ( +
+ + +
+ + + +
+
+ ) +} + +export const BaseEntryHeader = memo(BaseEntryHeaderImpl) diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderActionsContainer.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderActionsContainer.tsx new file mode 100644 index 000000000..8375d22e0 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderActionsContainer.tsx @@ -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 ( +
+ + +
+ ) +} + +export const EntryHeaderActionsContainer = memo(EntryHeaderActionsContainerImpl) 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 new file mode 100644 index 000000000..232f163fb --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderMeta.tsx @@ -0,0 +1,37 @@ +import { AnimatePresence, m } from "motion/react" +import { memo } from "react" + +interface EntryTitleMeta { + title: string + description?: string +} + +interface EntryHeaderMetaProps { + entryTitleMeta: Nullable + shouldShow: boolean +} + +function EntryHeaderMetaImpl({ entryTitleMeta, shouldShow }: EntryHeaderMetaProps) { + return ( +
+ + {shouldShow && entryTitleMeta && ( + + {entryTitleMeta.title} + + + {entryTitleMeta.description} + + + )} + +
+ ) +} + +export const EntryHeaderMeta = memo(EntryHeaderMetaImpl) diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderReadHistory.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderReadHistory.tsx new file mode 100644 index 000000000..6a137aaa5 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-header/internal/EntryHeaderReadHistory.tsx @@ -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 ( +
+ +
+ ) +} + +export const EntryHeaderReadHistory = memo(EntryHeaderReadHistoryImpl) diff --git a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/[entryId]/index.tsx b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/[entryId]/index.tsx index c24a2cc6b..849def4e2 100644 --- a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/[entryId]/index.tsx +++ b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/[entryId]/index.tsx @@ -1 +1 @@ -export { RightContentLayout as Component } from "~/modules/app-layout/entry-content/index" +export { EntryLayoutContent as Component } from "~/modules/app-layout/entry-content/index" diff --git a/packages/internal/store/src/entry/hooks.ts b/packages/internal/store/src/entry/hooks.ts index 9f3e6e3ca..81f95c367 100644 --- a/packages/internal/store/src/entry/hooks.ts +++ b/packages/internal/store/src/entry/hooks.ts @@ -140,7 +140,9 @@ export function useEntry( return selector(entry) }) } - +export const useHasEntry = (id: string) => { + return useEntryStore((state) => !!state.data[id]) +} export function useEntryList(ids: string[]): Array export function useEntryList(ids: string[], selector: (state: EntryModel) => T): T[] | undefined export function useEntryList(