diff --git a/apps/desktop/layer/renderer/src/App.tsx b/apps/desktop/layer/renderer/src/App.tsx index e8792ebfd..136a0308a 100644 --- a/apps/desktop/layer/renderer/src/App.tsx +++ b/apps/desktop/layer/renderer/src/App.tsx @@ -62,19 +62,15 @@ const AppLayer = () => { } const AppSkeleton = () => { - const entryColWidth = useUISettingKey("entryColWidth") const feedColWidth = useUISettingKey("feedColWidth") return (
-
-
-
) } diff --git a/apps/desktop/layer/renderer/src/atoms/debug-feature.ts b/apps/desktop/layer/renderer/src/atoms/debug-feature.ts new file mode 100644 index 000000000..0e80022ab --- /dev/null +++ b/apps/desktop/layer/renderer/src/atoms/debug-feature.ts @@ -0,0 +1,7 @@ +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"), {}), +) diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx b/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx index 4c1e89c6c..464ee5d33 100644 --- a/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx +++ b/apps/desktop/layer/renderer/src/hooks/biz/useEntryActions.tsx @@ -21,7 +21,6 @@ import { setReadabilityStatus, useEntryIsInReadability, } from "~/atoms/readability" -import { useServerConfigs } from "~/atoms/server-configs" import { useAIChatPinned } from "~/atoms/settings/ai" import { useShowSourceContent } from "~/atoms/source-content" import { ipcServices } from "~/lib/client" @@ -31,6 +30,7 @@ import { useCommandShortcuts } from "~/modules/command/hooks/use-command-binding import type { FollowCommandId } from "~/modules/command/types" import { useToolbarOrderMap } from "~/modules/customize-toolbar/hooks" +import { useFeature } from "./useFeature" import { useRouteParams } from "./useRouteParams" export const enableEntryReadability = async ({ id, url }: { id: string; url: string }) => { @@ -189,7 +189,7 @@ export const useEntryActions = ({ const isShowAITranslationAuto = useShowAITranslationAuto(!!entry?.translation) const isShowAITranslationOnce = useShowAITranslationOnce() const isShowAIChatPinned = useAIChatPinned() - const aiEnabled = useServerConfigs()?.AI_CHAT_ENABLED + const aiEnabled = useFeature("ai") const runCmdFn = useRunCommandFn() const hasEntry = !!entry diff --git a/apps/desktop/layer/renderer/src/hooks/biz/useFeature.ts b/apps/desktop/layer/renderer/src/hooks/biz/useFeature.ts new file mode 100644 index 000000000..aa73267b1 --- /dev/null +++ b/apps/desktop/layer/renderer/src/hooks/biz/useFeature.ts @@ -0,0 +1,12 @@ +import { useDebugFeatureValue } from "~/atoms/debug-feature" +import { useServerConfigs } from "~/atoms/server-configs" +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] + return isEnabled +} diff --git a/apps/desktop/layer/renderer/src/lib/features.tsx b/apps/desktop/layer/renderer/src/lib/features.tsx new file mode 100644 index 000000000..a1001f0ff --- /dev/null +++ b/apps/desktop/layer/renderer/src/lib/features.tsx @@ -0,0 +1,20 @@ +import type { ServerConfigs } from "@follow/models/types" +import type { FC } from "react" + +import { useFeature } from "~/hooks/biz/useFeature" + +export const featureConfigMap: Record = { + ai: "AI_CHAT_ENABLED", +} + +export const withFeature = + (feature: keyof typeof featureConfigMap) => + (Component: FC, FallbackComponent: FC) => { + const WithFeature = ({ ...props }: T) => { + const isEnabled = useFeature(feature) + + return isEnabled ? : + } + + return WithFeature + } diff --git a/apps/desktop/layer/renderer/src/modules/app-layout/ai/AIChatLayout.tsx b/apps/desktop/layer/renderer/src/modules/app-layout/ai/AIChatLayout.tsx index 61254e7d0..944d9eb75 100644 --- a/apps/desktop/layer/renderer/src/modules/app-layout/ai/AIChatLayout.tsx +++ b/apps/desktop/layer/renderer/src/modules/app-layout/ai/AIChatLayout.tsx @@ -5,12 +5,13 @@ import { AIChatRoot } from "~/modules/ai/chat/components/AIChatRoot" import { ChatHeader } from "./components/ChatHeader" import { ChatInterface } from "./components/ChatInterface" -export const AIChatLayout = () => { +export const AIChatLayout = ({ style }: { style?: React.CSSProperties }) => { return ( 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 5e4803db8..bd1d2c4a9 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 @@ -7,13 +7,13 @@ import { useMemo, useRef } from "react" import { useResizable } from "react-resizable-layout" import { useParams } from "react-router" -import { useServerConfigs } from "~/atoms/server-configs" import { setAIChatPinned, useAIChatPinned } from "~/atoms/settings/ai" import { useRealInWideMode } from "~/atoms/settings/ui" import { useTimelineColumnShow, useTimelineColumnTempShow } from "~/atoms/sidebar" import { m } from "~/components/common/Motion" import { FixedModalCloseButton } from "~/components/ui/modal/components/close" import { ROUTE_ENTRY_PENDING } from "~/constants" +import { useFeature } from "~/hooks/biz/useFeature" import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry" import { useRouteParams } from "~/hooks/biz/useRouteParams" import { AIChatRoot } from "~/modules/ai/chat/components/AIChatRoot" @@ -68,7 +68,7 @@ const EntryLayoutContentLegacy = () => { ) } -const EntryLayoutContentWithAI = () => { +export const EntryLayoutContentWithAI = () => { const { entryId, view } = useRouteParams() const navigate = useNavigateEntry() @@ -97,8 +97,8 @@ const EntryLayoutContentWithAI = () => { } export const EntryLayoutContent = () => { - const serverConfigs = useServerConfigs() - if (serverConfigs?.AI_CHAT_ENABLED) { + const aiEnabled = useFeature("ai") + if (aiEnabled) { return } return diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnLayout.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnLayout.tsx new file mode 100644 index 000000000..8214905e8 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnLayout.tsx @@ -0,0 +1,193 @@ +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 { useResizable } from "react-resizable-layout" +import { useParams } from "react-router" + +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" + +import { EntryColumn } from "./index" + +export const EntryColumnLayout = () => { + const { entryId } = useParams() + const navigate = useNavigateEntry() + + 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) + const { position, separatorProps, isDragging, separatorCursor, setPosition } = useResizable({ + axis: "x", + min: 300, + max: 600, + initial: aiColWidth, + reverse: true, + onResizeStart({ position }) { + startDragPosition.current = position + }, + onResizeEnd({ position }) { + if (position === startDragPosition.current) return + setUISetting("aiColWidth", position) + // TODO: Remove this after useMeasure can get bounds in time + window.dispatchEvent(new Event("resize")) + }, + }) + + return ( +
+
+ +
+ {/* Entry list - always rendered to prevent animation */} + + + {/* Entry content overlay with exit animation */} + + {realEntryId && ( + + {/* Scroll hint indicator */} +
+ +
+ +
+ )} +
+
+
+
+ { + setUISetting("aiColWidth", defaultUISettings.aiColWidth) + setPosition(defaultUISettings.aiColWidth) + }} + /> + +
+ ) +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/Items/article-item.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/Items/article-item.tsx index f7e34ba2b..f3d3fa54e 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/Items/article-item.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/Items/article-item.tsx @@ -6,12 +6,15 @@ import { ListItem } from "~/modules/entry-column/templates/list-item-template" import { FeedIcon } from "~/modules/feed/feed-icon" import { FeedTitle } from "~/modules/feed/feed-title" +import { readableContentMaxWidth } from "../styles" import type { EntryItemStatelessProps, UniversalItemProps } from "../types" export function ArticleItem({ entryId, entryPreview, translation }: UniversalItemProps) { return } +ArticleItem.wrapperClassName = readableContentMaxWidth + export function ArticleItemStateLess({ entry, feed }: EntryItemStatelessProps) { return (
diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/Items/notification-item.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/Items/notification-item.tsx index ebd39314e..9da1d58ed 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/Items/notification-item.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/Items/notification-item.tsx @@ -5,12 +5,15 @@ import { ListItem } from "~/modules/entry-column/templates/list-item-template" import { FeedIcon } from "~/modules/feed/feed-icon" import { FeedTitle } from "~/modules/feed/feed-title" +import { readableContentMaxWidth } from "../styles" import type { EntryItemStatelessProps, UniversalItemProps } from "../types" export function NotificationItem({ entryId, entryPreview, translation }: UniversalItemProps) { return } +NotificationItem.wrapperClassName = readableContentMaxWidth + export function NotificationItemStateLess({ entry, feed }: EntryItemStatelessProps) { return (
diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/Items/social-media-item.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/Items/social-media-item.tsx index 5dcb06567..23c9a9e3f 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/Items/social-media-item.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/Items/social-media-item.tsx @@ -1,9 +1,6 @@ -import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focusable/hooks.js" import { PassviseFragment } from "@follow/components/common/Fragment.js" -import { Spring } from "@follow/components/constants/spring.js" import { AutoResizeHeight } from "@follow/components/ui/auto-resize-height/index.js" import { Skeleton } from "@follow/components/ui/skeleton/index.jsx" -import { FeedViewType } from "@follow/constants" import { useIsEntryStarred } from "@follow/store/collection/hooks" import { useEntry } from "@follow/store/entry/hooks" import { useFeedById } from "@follow/store/feed/hooks" @@ -11,28 +8,24 @@ import { getImageProxyUrl } from "@follow/utils/img-proxy" import { LRUCache } from "@follow/utils/lru-cache" import { cn } from "@follow/utils/utils" import { atom } from "jotai" -import { AnimatePresence, m } from "motion/react" -import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react" +import { useLayoutEffect, useMemo, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { useGeneralSettingKey } from "~/atoms/settings/general" -import { FocusablePresets } from "~/components/common/Focusable" import { RelativeTime } from "~/components/ui/datetime" import { HTML } from "~/components/ui/markdown/HTML" import { usePreviewMedia } from "~/components/ui/media/hooks" import { Media } from "~/components/ui/media/Media" import { useEntryIsRead } from "~/hooks/biz/useAsRead" -import { useSortedEntryActions } from "~/hooks/biz/useEntryActions" import { useRenderStyle } from "~/hooks/biz/useRenderStyle" import { jotaiStore } from "~/lib/jotai" import { parseSocialMedia } from "~/lib/parsers" -import { EntryHeaderActions } from "~/modules/entry-content/actions/header-actions" -import { MoreActions } from "~/modules/entry-content/actions/more-actions" import type { FeedIconEntry } from "~/modules/feed/feed-icon" import { FeedIcon } from "~/modules/feed/feed-icon" import { FeedTitle } from "~/modules/feed/feed-title" import { StarIcon } from "../star-icon" +import { readableContentMaxWidth } from "../styles" import type { EntryItemStatelessProps, EntryListItemFC } from "../types" const socialMediaContentWidthAtom = atom(0) @@ -68,32 +61,6 @@ export const SocialMediaItem: EntryListItemFC = ({ entryId, translation }) => { const feed = useFeedById(entry?.feedId) const ref = useRef(null) - const [showAction, setShowAction] = useState(false) - - const handleMouseEnter = useMemo(() => { - return () => setShowAction(true) - }, []) - const handleMouseLeave = useMemo(() => { - return (e: React.MouseEvent) => { - // If the mouse is over the action bar, don't hide the action bar - const { relatedTarget, currentTarget } = e - if (relatedTarget && relatedTarget instanceof Node && currentTarget.contains(relatedTarget)) { - return - } - setShowAction(false) - } - }, []) - - const isDropdownMenuOpen = useGlobalFocusableScopeSelector( - FocusablePresets.isNotFloatingLayerScope, - ) - - useEffect(() => { - // Hide the action bar when dropdown menu is open and click outside - if (isDropdownMenuOpen) { - setShowAction(false) - } - }, [isDropdownMenuOpen]) useLayoutEffect(() => { if (ref.current) { @@ -115,13 +82,11 @@ export const SocialMediaItem: EntryListItemFC = ({ entryId, translation }) => { return (
@@ -168,37 +133,11 @@ export const SocialMediaItem: EntryListItemFC = ({ entryId, translation }) => {
- - {showAction && }
) } -SocialMediaItem.wrapperClassName = tw`w-[645px] max-w-full m-auto` - -const ActionBar = ({ entryId }: { entryId: string }) => { - const { mainAction: entryActions } = useSortedEntryActions({ - entryId, - view: FeedViewType.SocialMedia, - }) - - if (entryActions.length === 0) return null - - return ( - -
- - -
-
- ) -} +SocialMediaItem.wrapperClassName = readableContentMaxWidth export function SocialMediaItemStateLess({ entry, feed }: EntryItemStatelessProps) { return ( diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/components/DateItem.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/components/DateItem.tsx index 117a064fc..8d93d19a8 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/components/DateItem.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/components/DateItem.tsx @@ -39,7 +39,7 @@ const useParseDate = (date: string) => } }, [date]) -const dateItemclassName = tw`relative flex items-center text-sm lg:text-base gap-1 bg-background px-4 font-bold text-text h-7` +const dateItemclassName = tw`relative flex items-center text-sm lg:text-base gap-1 px-4 font-bold text-text h-7` export const DateItem = memo(({ date, view, isSticky }: DateItemProps) => { if (view === FeedViewType.SocialMedia) { return @@ -92,7 +92,7 @@ const DateItemInner: FC = ({ ) return (
= memo( {isStickyItem && (
= memo( style={useMemo( () => ({ transform, - paddingTop: isStickyItem ? "1.75rem" : undefined, + paddingTop: isStickyItem ? "2.75rem" : undefined, }), [transform, isStickyItem], )} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx index e935198ff..2d9ab776d 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx @@ -1,13 +1,13 @@ import { useGlobalFocusableScopeSelector } from "@follow/components/common/Focusable/hooks.js" +import { Spring } from "@follow/components/constants/spring.js" import { useMobile } from "@follow/components/hooks/useMobile.js" -import type { FeedViewType } from "@follow/constants" -import { views } from "@follow/constants" +import { FeedViewType, views } from "@follow/constants" import { useEntry } from "@follow/store/entry/hooks" import { unreadSyncService } from "@follow/store/unread/store" -import { EventBus } from "@follow/utils/event-bus" import { cn } from "@follow/utils/utils" +import { AnimatePresence, m } from "motion/react" import type { FC, MouseEvent, MouseEventHandler, PropsWithChildren, TouchEvent } from "react" -import { useCallback, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" import { NavLink } from "react-router" import { useDebounceCallback } from "usehooks-ts" @@ -22,13 +22,20 @@ import { useGeneralSettingKey } from "~/atoms/settings/general" import { FocusablePresets } from "~/components/common/Focusable" import { useEntryIsRead } from "~/hooks/biz/useAsRead" import { useContextMenuActionShortCutTrigger } from "~/hooks/biz/useContextMenuActionShortCutTrigger" -import { HIDE_ACTIONS_IN_ENTRY_CONTEXT_MENU, useEntryActions } from "~/hooks/biz/useEntryActions" +import { + HIDE_ACTIONS_IN_ENTRY_CONTEXT_MENU, + useEntryActions, + useSortedEntryActions, +} from "~/hooks/biz/useEntryActions" +import { useFeature } from "~/hooks/biz/useFeature" import { useFeedActions } from "~/hooks/biz/useFeedActions" import { getNavigateEntryPath, useNavigateEntry } from "~/hooks/biz/useNavigateEntry" -import { getRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams" +import { getRouteParams, useRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams" import { useContextMenu } from "~/hooks/common/useContextMenu" import { copyToClipboard } from "~/lib/clipboard" import { COMMAND_ID } from "~/modules/command/commands/id" +import { EntryHeaderActions } from "~/modules/entry-content/actions/header-actions" +import { MoreActions } from "~/modules/entry-content/actions/more-actions" export const EntryItemWrapper: FC< { @@ -61,7 +68,8 @@ export const EntryItemWrapper: FC< const asRead = useEntryIsRead(entry) const hoverMarkUnread = useGeneralSettingKey("hoverMarkUnread") - const handleMouseEnter = useDebounceCallback( + const [showAction, setShowAction] = useState(false) + const handleMouseEnterMarkRead = useDebounceCallback( () => { if (!hoverMarkUnread) return if (!document.hasFocus()) return @@ -76,14 +84,43 @@ export const EntryItemWrapper: FC< }, ) - const navigate = useNavigateEntry() + const handleMouseEnter = useMemo(() => { + return () => { + setShowAction(true) + handleMouseEnterMarkRead() + } + }, [handleMouseEnterMarkRead]) + const handleMouseLeave = useMemo(() => { + return (e: React.MouseEvent) => { + handleMouseEnterMarkRead.cancel() + // If the mouse is over the action bar, don't hide the action bar + const { relatedTarget, currentTarget } = e + if (relatedTarget && relatedTarget instanceof Node && currentTarget.contains(relatedTarget)) { + return + } + setShowAction(false) + } + }, [handleMouseEnterMarkRead]) + const isDropdownMenuOpen = useGlobalFocusableScopeSelector( + FocusablePresets.isNotFloatingLayerScope, + ) + + useEffect(() => { + // Hide the action bar when dropdown menu is open and click outside + if (isDropdownMenuOpen) { + setShowAction(false) + } + }, [isDropdownMenuOpen]) + + const navigate = useNavigateEntry() const navigationPath = useMemo(() => { if (!entry?.id) return "#" return getNavigateEntryPath({ entryId: entry?.id, }) }, [entry?.id]) + const handleClick = useCallback( (e: TouchEvent | MouseEvent) => { e.preventDefault() @@ -97,10 +134,11 @@ export const EntryItemWrapper: FC< unreadSyncService.markEntryAsRead(entry.id) } - setTimeout( - () => EventBus.dispatch(COMMAND_ID.layout.focusToEntryRender, { highlightBoundary: false }), - 60, - ) + // TODO + // setTimeout( + // () => EventBus.dispatch(COMMAND_ID.layout.focusToEntryRender, { highlightBoundary: false }), + // 60, + // ) navigate({ entryId: entry.id, @@ -168,25 +206,55 @@ export const EntryItemWrapper: FC< }, }) + const aiEnabled = useFeature("ai") + const isWide = views[view as FeedViewType]?.wideMode || aiEnabled + return (
{children} + {showAction && isWide && }
) } + +const ActionBar = ({ entryId }: { entryId: string }) => { + const { mainAction: entryActions } = useSortedEntryActions({ + entryId, + view: FeedViewType.SocialMedia, + }) + const { view } = useRouteParams() + + if (entryActions.length === 0) return null + + return ( + e.stopPropagation()} + > +
+ + +
+
+ ) +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/styles.ts b/apps/desktop/layer/renderer/src/modules/entry-column/styles.ts index c0bf10de4..c53559357 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/styles.ts +++ b/apps/desktop/layer/renderer/src/modules/entry-column/styles.ts @@ -1 +1,4 @@ export const girdClassNames = tw`grid grid-cols-1 @lg:grid-cols-2 @3xl:grid-cols-3 @6xl:grid-cols-4 @7xl:grid-cols-5 gap-1.5` + +// Shared max-width styles for readable content +export const readableContentMaxWidth = tw`max-w-[65ch] mx-auto px-4` diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.ai.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.ai.tsx new file mode 100644 index 000000000..426536a83 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.ai.tsx @@ -0,0 +1,348 @@ +import { useMobile } from "@follow/components/hooks/useMobile.js" +import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js" +import { useCollectionEntry, useIsEntryStarred } from "@follow/store/collection/hooks" +import { useEntry } from "@follow/store/entry/hooks" +import type { EntryModel } from "@follow/store/entry/types" +import { useFeedById } from "@follow/store/feed/hooks" +import { useInboxById } from "@follow/store/inbox/hooks" +import { clsx, cn, formatEstimatedMins, formatTimeToSeconds, isSafari } from "@follow/utils/utils" +import { useMemo } from "react" +import { titleCase } from "title-case" + +import { AudioPlayer, useAudioPlayerAtomSelector } from "~/atoms/player" +import { useGeneralSettingKey } from "~/atoms/settings/general" +import { useUISettingKey } from "~/atoms/settings/ui" +import { RelativeTime } from "~/components/ui/datetime" +import { Media } from "~/components/ui/media/Media" +import { FEED_COLLECTION_LIST } from "~/constants" +import { useEntryIsRead } from "~/hooks/biz/useAsRead" +import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams" +import { EntryTranslation } from "~/modules/entry-column/translation" +import type { FeedIconEntry } from "~/modules/feed/feed-icon" +import { FeedIcon } from "~/modules/feed/feed-icon" +import { FeedTitle } from "~/modules/feed/feed-title" +import { getPreferredTitle } from "~/store/feed/hooks" + +import { StarIcon } from "../star-icon" +import type { UniversalItemProps } from "../types" + +const entrySelector = (state: EntryModel) => { + const { feedId, inboxHandle, read } = state + const { authorAvatar, authorUrl, description, publishedAt, title } = state + + const audios = state.attachments?.filter((a) => a.mime_type?.startsWith("audio") && a.url) + const firstAudio = audios?.[0] + const media = state.media || [] + const firstMedia = media?.[0] + const photo = media.find((a) => a.type === "photo") + const firstPhotoUrl = photo?.url + const iconEntry: FeedIconEntry = { firstPhotoUrl, authorAvatar } + + const titleEntry = { authorUrl } + + return { + description, + feedId, + firstAudio, + firstMedia, + iconEntry, + inboxId: inboxHandle, + publishedAt, + read, + title, + titleEntry, + } +} +export function ListItem({ + entryId, + entryPreview, + translation, + simple, +}: UniversalItemProps & { + simple?: boolean +}) { + const isMobile = useMobile() + const entry = useEntry(entryId, entrySelector) + + const isInCollection = useIsEntryStarred(entryId) + const collectionCreatedAt = useCollectionEntry(entryId)?.createdAt + + const isRead = useEntryIsRead(entry) + + const inInCollection = useRouteParamsSelector((s) => s.feedId === FEED_COLLECTION_LIST) + + const feed = + useFeedById(entry?.feedId, (feed) => { + return { + type: feed.type, + ownerUserId: feed.ownerUserId, + id: feed.id, + title: feed.title, + url: (feed as any).url || "", + image: feed.image, + siteUrl: feed.siteUrl, + } + }) || entryPreview?.feeds + + const inbox = useInboxById(entry?.inboxId) + + const thumbnailRatio = useUISettingKey("thumbnailRatio") + const rid = `list-item-${entryId}` + + const bilingual = useGeneralSettingKey("translationMode") === "bilingual" + const lineClamp = useMemo(() => { + const envIsSafari = isSafari() + let lineClampTitle = 1 + let lineClampDescription = 2 + + if (translation?.title && !simple && bilingual) { + lineClampTitle += 1 + } + if (translation?.description && !simple && bilingual) { + lineClampDescription += 1 + } + + // FIXME: Safari bug, not support line-clamp cross elements + return { + global: !envIsSafari + ? `line-clamp-[${simple ? lineClampTitle : lineClampTitle + lineClampDescription}]` + : "", + title: envIsSafari ? `line-clamp-[${lineClampTitle}]` : "", + description: envIsSafari ? `line-clamp-[${lineClampDescription}]` : "", + } + }, [simple, translation?.description, translation?.title, bilingual]) + + const dimRead = useGeneralSettingKey("dimRead") + // NOTE: prevent 0 height element, react virtuoso will not stop render any more + if (!entry || !(feed || inbox)) return null + + const displayTime = inInCollection ? collectionCreatedAt : entry?.publishedAt + + const related = feed || inbox + + const hasAudio = simple ? false : !!entry.firstAudio?.url + const hasMedia = simple ? false : !!entry.firstMedia?.url + + const marginWidth = 8 * (isMobile ? 1.125 : 1) + // calculate the max width to have a correct truncation + // FIXME: this is not easy to maintain, need to refactor + const feedIconWidth = 20 + marginWidth + const audioCoverWidth = 80 + marginWidth + const mediaWidth = 80 * (isMobile ? 1.125 : 1) + marginWidth + + let savedWidth = 0 + + savedWidth += feedIconWidth + + if (hasAudio) { + savedWidth += audioCoverWidth + } + if (hasMedia && !hasAudio) { + savedWidth += mediaWidth + } + + return ( +
+ +
+
+ + + + · + {!!displayTime && } +
+
+ {entry?.title ? ( + + ) : ( + + )} + {!!isInCollection && } +
+ {!simple && ( +
+ +
+ )} +
+ + {hasAudio && entry.firstAudio && ( + + } + feed={feed || inbox} + entry={entry?.iconEntry} + size={80} + className="m-0 rounded" + useMedia + noMargin + /> + } + /> + )} + + {!simple && !hasAudio && entry.firstMedia && ( + + )} +
+ ) +} + +function AudioCover({ + entryId, + src, + durationInSeconds, + feedIcon, +}: { + entryId: string + src: string + durationInSeconds?: number | string + feedIcon: React.ReactNode +}) { + const isMobile = useMobile() + const playStatus = useAudioPlayerAtomSelector((playerValue) => + playerValue.src === src && playerValue.show ? playerValue.status : false, + ) + + const language = useGeneralSettingKey("language") + const isChinese = useMemo(() => { + return language === "zh-CN" + }, [language]) + + const seconds = formatTimeToSeconds(durationInSeconds) + const estimatedMins = seconds && Math.floor(seconds / 60) + + const handleClickPlay = (e: React.MouseEvent) => { + if (isMobile) e.stopPropagation() + if (!playStatus) { + // switch this to play + AudioPlayer.mount({ + type: "audio", + entryId, + src, + currentTime: 0, + }) + } else { + // switch between play and pause + AudioPlayer.togglePlayAndPause() + } + } + + return ( +
+ {feedIcon} + +
+ +
+ + {!!estimatedMins && ( +
+
+
+ {isChinese ? `${estimatedMins} 分钟` : formatEstimatedMins(estimatedMins)} +
+
+ )} +
+ ) +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.legacy.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.legacy.tsx new file mode 100644 index 000000000..4c66fe7c2 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.legacy.tsx @@ -0,0 +1,359 @@ +import { useMobile } from "@follow/components/hooks/useMobile.js" +import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js" +import { useCollectionEntry, useIsEntryStarred } from "@follow/store/collection/hooks" +import { useEntry } from "@follow/store/entry/hooks" +import type { EntryModel } from "@follow/store/entry/types" +import { useFeedById } from "@follow/store/feed/hooks" +import { useInboxById } from "@follow/store/inbox/hooks" +import { clsx, cn, formatEstimatedMins, formatTimeToSeconds, isSafari } from "@follow/utils/utils" +import { useMemo } from "react" +import { titleCase } from "title-case" + +import { AudioPlayer, useAudioPlayerAtomSelector } from "~/atoms/player" +import { useGeneralSettingKey } from "~/atoms/settings/general" +import { useRealInWideMode, useUISettingKey } from "~/atoms/settings/ui" +import { RelativeTime } from "~/components/ui/datetime" +import { Media } from "~/components/ui/media/Media" +import { FEED_COLLECTION_LIST } from "~/constants" +import { useEntryIsRead } from "~/hooks/biz/useAsRead" +import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams" +import { EntryTranslation } from "~/modules/entry-column/translation" +import type { FeedIconEntry } from "~/modules/feed/feed-icon" +import { FeedIcon } from "~/modules/feed/feed-icon" +import { FeedTitle } from "~/modules/feed/feed-title" +import { getPreferredTitle } from "~/store/feed/hooks" + +import { StarIcon } from "../star-icon" +import type { UniversalItemProps } from "../types" + +const entrySelector = (state: EntryModel) => { + const { feedId, inboxHandle, read } = state + const { authorAvatar, authorUrl, description, publishedAt, title } = state + + const audios = state.attachments?.filter((a) => a.mime_type?.startsWith("audio") && a.url) + const firstAudio = audios?.[0] + const media = state.media || [] + const firstMedia = media?.[0] + const photo = media.find((a) => a.type === "photo") + const firstPhotoUrl = photo?.url + const iconEntry: FeedIconEntry = { firstPhotoUrl, authorAvatar } + + const titleEntry = { authorUrl } + + return { + description, + feedId, + firstAudio, + firstMedia, + iconEntry, + inboxId: inboxHandle, + publishedAt, + read, + title, + titleEntry, + } +} +export function ListItem({ + entryId, + entryPreview, + translation, + simple, +}: UniversalItemProps & { + simple?: boolean +}) { + const isMobile = useMobile() + const entry = useEntry(entryId, entrySelector) + + const isInCollection = useIsEntryStarred(entryId) + const collectionCreatedAt = useCollectionEntry(entryId)?.createdAt + + const isRead = useEntryIsRead(entry) + + const inInCollection = useRouteParamsSelector((s) => s.feedId === FEED_COLLECTION_LIST) + + const feed = + useFeedById(entry?.feedId, (feed) => { + return { + type: feed.type, + ownerUserId: feed.ownerUserId, + id: feed.id, + title: feed.title, + url: (feed as any).url || "", + image: feed.image, + siteUrl: feed.siteUrl, + } + }) || entryPreview?.feeds + + const inbox = useInboxById(entry?.inboxId) + + const settingWideMode = useRealInWideMode() + const thumbnailRatio = useUISettingKey("thumbnailRatio") + const rid = `list-item-${entryId}` + + const bilingual = useGeneralSettingKey("translationMode") === "bilingual" + const lineClamp = useMemo(() => { + const envIsSafari = isSafari() + let lineClampTitle = settingWideMode ? 1 : 2 + let lineClampDescription = settingWideMode ? 1 : 2 + + if (translation?.title && !simple && bilingual) { + lineClampTitle += 1 + } + if (translation?.description && !simple && bilingual) { + lineClampDescription += 1 + } + + // FIXME: Safari bug, not support line-clamp cross elements + return { + global: !envIsSafari + ? `line-clamp-[${simple ? lineClampTitle : lineClampTitle + lineClampDescription}]` + : "", + title: envIsSafari ? `line-clamp-[${lineClampTitle}]` : "", + description: envIsSafari ? `line-clamp-[${lineClampDescription}]` : "", + } + }, [settingWideMode, simple, translation?.description, translation?.title, bilingual]) + + const dimRead = useGeneralSettingKey("dimRead") + // NOTE: prevent 0 height element, react virtuoso will not stop render any more + if (!entry || !(feed || inbox)) return null + + const displayTime = inInCollection ? collectionCreatedAt : entry?.publishedAt + + const related = feed || inbox + + const hasAudio = simple ? false : !!entry.firstAudio?.url + const hasMedia = simple ? false : !!entry.firstMedia?.url + + const marginWidth = 8 * (isMobile ? 1.125 : 1) + // calculate the max width to have a correct truncation + // FIXME: this is not easy to maintain, need to refactor + const feedIconWidth = 20 + marginWidth + const audioCoverWidth = settingWideMode ? 65 : 80 + marginWidth + const mediaWidth = (settingWideMode ? 48 : 80) * (isMobile ? 1.125 : 1) + marginWidth + + let savedWidth = 0 + + savedWidth += feedIconWidth + + if (hasAudio) { + savedWidth += audioCoverWidth + } + if (hasMedia && !hasAudio) { + savedWidth += mediaWidth + } + + return ( +
+ +
+
+ + + + · + {!!displayTime && } +
+
+ {entry?.title ? ( + + ) : ( + + )} + {!!isInCollection && } +
+ {!simple && ( +
+ +
+ )} +
+ + {hasAudio && entry.firstAudio && ( + + } + feed={feed || inbox} + entry={entry?.iconEntry} + size={settingWideMode ? 65 : 80} + className="m-0 rounded" + useMedia + noMargin + /> + } + /> + )} + + {!simple && !hasAudio && entry.firstMedia && ( + + )} +
+ ) +} + +function AudioCover({ + entryId, + src, + durationInSeconds, + feedIcon, +}: { + entryId: string + src: string + durationInSeconds?: number | string + feedIcon: React.ReactNode +}) { + const isMobile = useMobile() + const playStatus = useAudioPlayerAtomSelector((playerValue) => + playerValue.src === src && playerValue.show ? playerValue.status : false, + ) + + const language = useGeneralSettingKey("language") + const isChinese = useMemo(() => { + return language === "zh-CN" + }, [language]) + + const seconds = formatTimeToSeconds(durationInSeconds) + const estimatedMins = seconds && Math.floor(seconds / 60) + + const handleClickPlay = (e: React.MouseEvent) => { + if (isMobile) e.stopPropagation() + if (!playStatus) { + // switch this to play + AudioPlayer.mount({ + type: "audio", + entryId, + src, + currentTime: 0, + }) + } else { + // switch between play and pause + AudioPlayer.togglePlayAndPause() + } + } + + return ( +
+ {feedIcon} + +
+ +
+ + {!!estimatedMins && ( +
+
+
+ {isChinese ? `${estimatedMins} 分钟` : formatEstimatedMins(estimatedMins)} +
+
+ )} +
+ ) +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.tsx index 2f5a6577b..fa9dbb971 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/templates/list-item-template.tsx @@ -1,359 +1,6 @@ -import { useMobile } from "@follow/components/hooks/useMobile.js" -import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js" -import { useCollectionEntry, useIsEntryStarred } from "@follow/store/collection/hooks" -import { useEntry } from "@follow/store/entry/hooks" -import type { EntryModel } from "@follow/store/entry/types" -import { useFeedById } from "@follow/store/feed/hooks" -import { useInboxById } from "@follow/store/inbox/hooks" -import { clsx, cn, formatEstimatedMins, formatTimeToSeconds, isSafari } from "@follow/utils/utils" -import { useMemo } from "react" -import { titleCase } from "title-case" +import { withFeature } from "~/lib/features" -import { AudioPlayer, useAudioPlayerAtomSelector } from "~/atoms/player" -import { useGeneralSettingKey } from "~/atoms/settings/general" -import { useRealInWideMode, useUISettingKey } from "~/atoms/settings/ui" -import { RelativeTime } from "~/components/ui/datetime" -import { Media } from "~/components/ui/media/Media" -import { FEED_COLLECTION_LIST } from "~/constants" -import { useEntryIsRead } from "~/hooks/biz/useAsRead" -import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams" -import { EntryTranslation } from "~/modules/entry-column/translation" -import type { FeedIconEntry } from "~/modules/feed/feed-icon" -import { FeedIcon } from "~/modules/feed/feed-icon" -import { FeedTitle } from "~/modules/feed/feed-title" -import { getPreferredTitle } from "~/store/feed/hooks" +import { ListItem as ListItemAI } from "./list-item-template.ai" +import { ListItem as ListItemLegacy } from "./list-item-template.legacy" -import { StarIcon } from "../star-icon" -import type { UniversalItemProps } from "../types" - -const entrySelector = (state: EntryModel) => { - const { feedId, inboxHandle, read } = state - const { authorAvatar, authorUrl, description, publishedAt, title } = state - - const audios = state.attachments?.filter((a) => a.mime_type?.startsWith("audio") && a.url) - const firstAudio = audios?.[0] - const media = state.media || [] - const firstMedia = media?.[0] - const photo = media.find((a) => a.type === "photo") - const firstPhotoUrl = photo?.url - const iconEntry: FeedIconEntry = { firstPhotoUrl, authorAvatar } - - const titleEntry = { authorUrl } - - return { - description, - feedId, - firstAudio, - firstMedia, - iconEntry, - inboxId: inboxHandle, - publishedAt, - read, - title, - titleEntry, - } -} -export function ListItem({ - entryId, - entryPreview, - translation, - simple, -}: UniversalItemProps & { - simple?: boolean -}) { - const isMobile = useMobile() - const entry = useEntry(entryId, entrySelector) - - const isInCollection = useIsEntryStarred(entryId) - const collectionCreatedAt = useCollectionEntry(entryId)?.createdAt - - const isRead = useEntryIsRead(entry) - - const inInCollection = useRouteParamsSelector((s) => s.feedId === FEED_COLLECTION_LIST) - - const feed = - useFeedById(entry?.feedId, (feed) => { - return { - type: feed.type, - ownerUserId: feed.ownerUserId, - id: feed.id, - title: feed.title, - url: (feed as any).url || "", - image: feed.image, - siteUrl: feed.siteUrl, - } - }) || entryPreview?.feeds - - const inbox = useInboxById(entry?.inboxId) - - const settingWideMode = useRealInWideMode() - const thumbnailRatio = useUISettingKey("thumbnailRatio") - const rid = `list-item-${entryId}` - - const bilingual = useGeneralSettingKey("translationMode") === "bilingual" - const lineClamp = useMemo(() => { - const envIsSafari = isSafari() - let lineClampTitle = settingWideMode ? 1 : 2 - let lineClampDescription = settingWideMode ? 1 : 2 - - if (translation?.title && !simple && bilingual) { - lineClampTitle += 1 - } - if (translation?.description && !simple && bilingual) { - lineClampDescription += 1 - } - - // FIXME: Safari bug, not support line-clamp cross elements - return { - global: !envIsSafari - ? `line-clamp-[${simple ? lineClampTitle : lineClampTitle + lineClampDescription}]` - : "", - title: envIsSafari ? `line-clamp-[${lineClampTitle}]` : "", - description: envIsSafari ? `line-clamp-[${lineClampDescription}]` : "", - } - }, [settingWideMode, simple, translation?.description, translation?.title, bilingual]) - - const dimRead = useGeneralSettingKey("dimRead") - // NOTE: prevent 0 height element, react virtuoso will not stop render any more - if (!entry || !(feed || inbox)) return null - - const displayTime = inInCollection ? collectionCreatedAt : entry?.publishedAt - - const related = feed || inbox - - const hasAudio = simple ? false : !!entry.firstAudio?.url - const hasMedia = simple ? false : !!entry.firstMedia?.url - - const marginWidth = 8 * (isMobile ? 1.125 : 1) - // calculate the max width to have a correct truncation - // FIXME: this is not easy to maintain, need to refactor - const feedIconWidth = 20 + marginWidth - const audioCoverWidth = settingWideMode ? 65 : 80 + marginWidth - const mediaWidth = (settingWideMode ? 48 : 80) * (isMobile ? 1.125 : 1) + marginWidth - - let savedWidth = 0 - - savedWidth += feedIconWidth - - if (hasAudio) { - savedWidth += audioCoverWidth - } - if (hasMedia && !hasAudio) { - savedWidth += mediaWidth - } - - return ( -
- -
-
- - - - · - {!!displayTime && } -
-
- {entry?.title ? ( - - ) : ( - - )} - {!!isInCollection && } -
- {!simple && ( -
- -
- )} -
- - {hasAudio && entry.firstAudio && ( - - } - feed={feed || inbox} - entry={entry?.iconEntry} - size={settingWideMode ? 65 : 80} - className="m-0 rounded" - useMedia - noMargin - /> - } - /> - )} - - {!simple && !hasAudio && entry.firstMedia && ( - - )} -
- ) -} - -function AudioCover({ - entryId, - src, - durationInSeconds, - feedIcon, -}: { - entryId: string - src: string - durationInSeconds?: number | string - feedIcon: React.ReactNode -}) { - const isMobile = useMobile() - const playStatus = useAudioPlayerAtomSelector((playerValue) => - playerValue.src === src && playerValue.show ? playerValue.status : false, - ) - - const language = useGeneralSettingKey("language") - const isChinese = useMemo(() => { - return language === "zh-CN" - }, [language]) - - const seconds = formatTimeToSeconds(durationInSeconds) - const estimatedMins = seconds && Math.floor(seconds / 60) - - const handleClickPlay = (e: React.MouseEvent) => { - if (isMobile) e.stopPropagation() - if (!playStatus) { - // switch this to play - AudioPlayer.mount({ - type: "audio", - entryId, - src, - currentTime: 0, - }) - } else { - // switch between play and pause - AudioPlayer.togglePlayAndPause() - } - } - - return ( -
- {feedIcon} - -
- -
- - {!!estimatedMins && ( -
-
-
- {isChinese ? `${estimatedMins} 分钟` : formatEstimatedMins(estimatedMins)} -
-
- )} -
- ) -} +export const ListItem = withFeature("ai")(ListItemAI, ListItemLegacy) diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/actions/header-actions.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/actions/header-actions.tsx index 821e442f0..9f53c80a8 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/actions/header-actions.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/actions/header-actions.tsx @@ -30,6 +30,7 @@ export const EntryHeaderActions = ({ clickableDisabled={config.disabled} highlightMotion={config.notice} id={`${config.entryId}/${config.id}`} + size={compact ? "sm" : "base"} /> ) }) diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/actions/more-actions.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/actions/more-actions.tsx index 7fc4abf65..2f7a3e17a 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/actions/more-actions.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/actions/more-actions.tsx @@ -16,7 +16,15 @@ import { COMMAND_ID } from "~/modules/command/commands/id" import { hasCommand, useCommand } from "~/modules/command/hooks/use-command" import type { FollowCommandId } from "~/modules/command/types" -export const MoreActions = ({ entryId, view }: { entryId: string; view: FeedViewType }) => { +export const MoreActions = ({ + entryId, + view, + compact, +}: { + entryId: string + view: FeedViewType + compact?: boolean +}) => { const { moreAction } = useSortedEntryActions({ entryId, view }) const actionConfigs = useMemo( @@ -47,7 +55,10 @@ export const MoreActions = ({ entryId, view }: { entryId: string; view: FeedView return ( - } /> + } + size={compact ? "sm" : "base"} + /> diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.tsx index 92ee607c3..0e8f05596 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.tsx @@ -255,6 +255,7 @@ const EntryScrollArea: Component<{ { @@ -49,7 +49,7 @@ const useReadPercent = () => { const BackTopIndicator: Component = memo(({ className }) => { const [readPercent] = useReadPercent() const scrollElement = useScrollViewElement() - const aiEnabled = useServerConfigs()?.AI_CHAT_ENABLED + const aiEnabled = useFeature("ai") const isAiPanelOpen = useAIChatPinned() diff --git a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/layout.tsx b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/layout.tsx index 673123dde..0cbaccecb 100644 --- a/apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/layout.tsx +++ b/apps/desktop/layer/renderer/src/pages/(main)/(layer)/timeline/[timelineId]/[feedId]/layout.tsx @@ -1 +1,5 @@ -export { CenterColumnLayout as Component } from "~/modules/app-layout/timeline-column/index" +import { withFeature } from "~/lib/features" +import { CenterColumnLayout } from "~/modules/app-layout/timeline-column/index" +import { EntryColumnLayout } from "~/modules/entry-column/EntryColumnLayout" + +export const Component = withFeature("ai")(EntryColumnLayout, CenterColumnLayout) diff --git a/icons/mgc/up_cute_re.svg b/icons/mgc/up_cute_re.svg new file mode 100644 index 000000000..5bd96c2d7 --- /dev/null +++ b/icons/mgc/up_cute_re.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/internal/components/package.json b/packages/internal/components/package.json index 22e240ea0..a229a5802 100644 --- a/packages/internal/components/package.json +++ b/packages/internal/components/package.json @@ -24,6 +24,7 @@ "dependencies": { "@essentials/request-timeout": "1.3.0", "@follow/hooks": "workspace:*", + "@follow/models": "workspace:*", "@follow/types": "workspace:*", "@follow/utils": "workspace:*", "@headlessui/react": "2.2.4", diff --git a/packages/internal/components/src/ui/button/action-button.tsx b/packages/internal/components/src/ui/button/action-button.tsx index 5959eb22f..a3701d164 100644 --- a/packages/internal/components/src/ui/button/action-button.tsx +++ b/packages/internal/components/src/ui/button/action-button.tsx @@ -47,7 +47,7 @@ const actionButtonStyleVariant = { size: { lg: tw`text-xl size-10`, base: tw`text-xl size-8`, - sm: tw`text-sm size-6`, + sm: tw`text-lg size-7`, }, } diff --git a/packages/internal/components/src/ui/scroll-area/ScrollArea.tsx b/packages/internal/components/src/ui/scroll-area/ScrollArea.tsx index acfd490ee..88b0d9b9e 100644 --- a/packages/internal/components/src/ui/scroll-area/ScrollArea.tsx +++ b/packages/internal/components/src/ui/scroll-area/ScrollArea.tsx @@ -146,6 +146,7 @@ export const ScrollArea = ({ asChild = false, onUpdateMaxScroll, focusable = true, + stopWheelPropagation = true, }: React.PropsWithChildren & { rootClassName?: string viewportClassName?: string @@ -157,6 +158,7 @@ export const ScrollArea = ({ orientation?: "vertical" | "horizontal" asChild?: boolean focusable?: boolean + stopWheelPropagation?: boolean } & { ref?: React.Ref }) => { const [viewportRef, setViewportRef] = React.useState(null) React.useImperativeHandle(ref, () => viewportRef as HTMLDivElement) @@ -169,7 +171,7 @@ export const ScrollArea = ({ div]:!flex [&>div]:!flex-col" : "", viewportClassName)} mask={mask} asChild={asChild} diff --git a/packages/internal/shared/src/settings/defaults.ts b/packages/internal/shared/src/settings/defaults.ts index c68b69db8..9cc885b92 100644 --- a/packages/internal/shared/src/settings/defaults.ts +++ b/packages/internal/shared/src/settings/defaults.ts @@ -49,6 +49,7 @@ export const defaultUISettings: UISettings = { // Sidebar entryColWidth: 356, + aiColWidth: 384, feedColWidth: 256, hideExtraBadge: false, diff --git a/packages/internal/shared/src/settings/interface.ts b/packages/internal/shared/src/settings/interface.ts index 5dae0fe53..fa61d4111 100644 --- a/packages/internal/shared/src/settings/interface.ts +++ b/packages/internal/shared/src/settings/interface.ts @@ -51,6 +51,7 @@ export type AccentColor = export interface UISettings { accentColor: AccentColor entryColWidth: number + aiColWidth: number feedColWidth: number opaqueSidebar: boolean sidebarShowUnreadCount: boolean diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 033187bd2..ee01e78de 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1395,6 +1395,9 @@ importers: '@follow/hooks': specifier: workspace:* version: link:../hooks + '@follow/models': + specifier: workspace:* + version: link:../models '@follow/types': specifier: workspace:* version: link:../types