feat: ai column (#4203)

* feat: remove the entry content column

* feat: remove entryColWidth

* feat: entry list styles

* feat: ai column

* feat: implement EntryColumnLayout and integrate routing for entry content

- Added EntryColumnLayout component to manage entry display and interactions.
- Integrated Outlet for nested routing within the CenterColumnLayout.
- Updated EntryItemWrapper to navigate to entry content instead of using a modal.
- Enhanced EntryContent with scroll handling and exit functionality.
- Introduced new SVG icon for scroll hint in entry content.

* fix: date item bg

* refactor: simplify line clamping and layout adjustments in ListItem component

- Removed the settingWideMode dependency for line clamping calculations, standardizing title and description clamping values.
- Adjusted media width and audio cover width calculations for consistent layout.
- Updated class names and styles for improved readability and maintainability.

* feat: enhance feature management and layout adjustments

- Introduced a new `withFeature` higher-order component to manage feature toggling for the ListItem component.
- Updated the ListItem component to conditionally render based on the AI feature flag.
- Adjusted layout settings for entry columns, including entryColWidth and improved responsiveness.
- Added new templates for list items to support different display modes.
- Updated the CenterColumnLayout to integrate with the new feature management system.

* feat: implement feature flag management for AI functionality

- Introduced `useFeature` hook to manage feature toggling based on server configurations.
- Updated components to utilize the new `useFeature` hook for AI-related features, enhancing modularity and maintainability.
- Refactored `EntryLayoutContent`, `EntryItemWrapper`, and other components to check AI feature status through the new hook.
- Added a legacy list item template to support fallback rendering when AI features are disabled.

* refactor: comment out unused setTimeout in EntryItemWrapper

- Commented out the setTimeout function that dispatches an event to focus on the entry render, marking it as a TODO for future implementation.
- This change improves code clarity and prepares for potential feature adjustments.

* feat: debug feature
This commit is contained in:
DIYgod 2025-07-18 19:21:23 +08:00 committed by GitHub
parent d79b00d976
commit 0e456d5b73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 1085 additions and 461 deletions

View File

@ -62,19 +62,15 @@ const AppLayer = () => {
}
const AppSkeleton = () => {
const entryColWidth = useUISettingKey("entryColWidth")
const feedColWidth = useUISettingKey("feedColWidth")
return (
<div className="flex size-full">
<div
className="h-full shrink-0"
className="bg-sidebar h-full shrink-0"
style={{
width: `${feedColWidth}px`,
}}
/>
<div className="bg-theme-background relative size-full grow">
<div className="bg-border absolute inset-y-0 w-px" style={{ left: entryColWidth }} />
</div>
</div>
)
}

View File

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

View File

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

View File

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

View File

@ -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<string, keyof ServerConfigs> = {
ai: "AI_CHAT_ENABLED",
}
export const withFeature =
(feature: keyof typeof featureConfigMap) =>
<T extends object>(Component: FC<T>, FallbackComponent: FC<T>) => {
const WithFeature = ({ ...props }: T) => {
const isEnabled = useFeature(feature)
return isEnabled ? <Component {...props} /> : <FallbackComponent {...props} />
}
return WithFeature
}

View File

@ -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 (
<AIChatRoot wrapFocusable={false}>
<Focusable
scope={HotkeyScope.AIChat}
className="bg-background relative flex size-full flex-col overflow-hidden"
className="bg-background relative flex h-full flex-col overflow-hidden"
style={style}
>
<ChatHeader />
<ChatInterface />

View File

@ -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 = () => {
</AppLayoutGridContainerProvider>
)
}
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 <EntryLayoutContentWithAI />
}
return <EntryLayoutContentLegacy />

View File

@ -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<HTMLDivElement>(null)
const accumulatedDelta = useRef(0)
const isScrollingAtTop = useRef(false)
const [showScrollHint, setShowScrollHint] = useState(false)
const handleCloseGesture = useCallback(() => {
navigate({ entryId: null })
}, [navigate])
const handleWheel = useCallback(
(e: WheelEvent) => {
if (!realEntryId || !entryContentRef.current) return
// Find the actual scroll viewport element with correct Radix UI attribute
const entryContentElement = entryContentRef.current.querySelector(
"[data-radix-scroll-area-viewport]",
) as HTMLElement
const scrollElement = entryContentElement || entryContentRef.current
// Check if we're at the top of the content
const scrollTop = scrollElement?.scrollTop || 0
isScrollingAtTop.current = scrollTop === 0
setShowScrollHint(scrollTop === 0)
// Handle trackpad/mouse wheel: upward scroll (deltaY < 0) or downward swipe gesture
// On macOS trackpad, natural scrolling makes upward finger movement negative deltaY
if (e.deltaY < 0 && isScrollingAtTop.current) {
e.preventDefault()
accumulatedDelta.current += Math.abs(e.deltaY)
// Close when accumulated scroll exceeds threshold (150px for trackpad sensitivity)
if (accumulatedDelta.current > 1000) {
handleCloseGesture()
accumulatedDelta.current = 0
}
} else {
// Reset accumulation when scrolling down or not at top
accumulatedDelta.current = 0
}
},
[realEntryId, handleCloseGesture],
)
useEffect(() => {
if (!realEntryId || !entryContentRef.current) return
const element = entryContentRef.current
// Find the scroll area viewport element with correct Radix UI attribute
const scrollViewport = element.querySelector("[data-radix-scroll-area-viewport]") as HTMLElement
// Add wheel event listener to both the main container and scroll viewport
// This ensures the gesture works in both header area and scrollable content
const elementsToListen: HTMLElement[] = [element]
if (scrollViewport) {
elementsToListen.push(scrollViewport)
}
elementsToListen.forEach((el) => {
el.addEventListener("wheel", handleWheel, { passive: false })
})
// Initial scroll position check for hint visibility
const initialCheckScrollPosition = () => {
const scrollTop = scrollViewport?.scrollTop || element.scrollTop || 0
setShowScrollHint(scrollTop === 0)
}
// Check initial position
initialCheckScrollPosition()
// Add scroll listener for hint visibility
const scrollElement = scrollViewport || element
scrollElement.addEventListener("scroll", initialCheckScrollPosition, { passive: true })
return () => {
elementsToListen.forEach((el) => {
el.removeEventListener("wheel", handleWheel)
})
scrollElement.removeEventListener("scroll", initialCheckScrollPosition)
}
}, [realEntryId, handleWheel])
// AI chat resizable panel configuration
const aiColWidth = useMemo(() => getUISettings().aiColWidth, [])
const startDragPosition = useRef(0)
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 (
<div className="relative flex min-w-0 grow">
<div className="h-full flex-1 border-r">
<AppLayoutGridContainerProvider>
<div className="relative h-full">
{/* Entry list - always rendered to prevent animation */}
<EntryColumn key="entry-list" />
{/* Entry content overlay with exit animation */}
<AnimatePresence mode="wait">
{realEntryId && (
<m.div
ref={entryContentRef}
key={realEntryId}
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{
type: "spring",
damping: 30,
stiffness: 400,
duration: 0.3,
}}
className="bg-theme-background absolute inset-0 z-10 border-l"
>
{/* Scroll hint indicator */}
<div className="center z-50 pt-2">
<Button
variant="ghost"
size="sm"
onClick={() => navigate({ entryId: null })}
buttonClassName="transform cursor-pointer select-none"
aria-label="Scroll up or click to exit"
>
<div className="text-text flex items-center gap-2 rounded-full font-medium">
<i
className={cn(
"text-base",
showScrollHint ? "i-mgc-up-cute-re" : "i-mgc-close-cute-re",
)}
/>
<span>{showScrollHint ? "Scroll up to exit" : "Click to exit"}</span>
</div>
</Button>
</div>
<EntryContent entryId={realEntryId} className="h-full" />
</m.div>
)}
</AnimatePresence>
</div>
</AppLayoutGridContainerProvider>
</div>
<PanelSplitter
{...separatorProps}
cursor={separatorCursor}
isDragging={isDragging}
onDoubleClick={() => {
setUISetting("aiColWidth", defaultUISettings.aiColWidth)
setPosition(defaultUISettings.aiColWidth)
}}
/>
<AIChatLayout style={{ width: position }} />
</div>
)
}

View File

@ -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 <ListItem entryId={entryId} entryPreview={entryPreview} translation={translation} />
}
ArticleItem.wrapperClassName = readableContentMaxWidth
export function ArticleItemStateLess({ entry, feed }: EntryItemStatelessProps) {
return (
<div className="text-text relative select-none rounded-md transition-colors">

View File

@ -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 <ListItem entryId={entryId} entryPreview={entryPreview} translation={translation} simple />
}
NotificationItem.wrapperClassName = readableContentMaxWidth
export function NotificationItemStateLess({ entry, feed }: EntryItemStatelessProps) {
return (
<div className="relative w-full max-w-lg select-none">

View File

@ -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<HTMLDivElement>(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 (
<div
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
className={cn(
"relative flex px-5 py-4 first:mt-6 lg:px-8",
"relative flex py-4",
"group",
!asRead &&
"before:bg-accent before:absolute before:left-1 before:top-8 before:block before:size-2 before:rounded-full md:before:-left-2 lg:before:left-2",
"before:bg-accent before:absolute before:-left-3 before:top-8 before:block before:size-2 before:rounded-full",
)}
>
<FeedIcon fallback feed={feed} entry={entry.iconEntry} size={32} className="mt-1" />
@ -168,37 +133,11 @@ export const SocialMediaItem: EntryListItemFC = ({ entryId, translation }) => {
</div>
<SocialMediaGallery entryId={entryId} />
</div>
<AnimatePresence>{showAction && <ActionBar entryId={entryId} />}</AnimatePresence>
</div>
)
}
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 (
<m.div
initial={{ opacity: 0, scale: 0.9, y: "-1/2" }}
animate={{ opacity: 1, scale: 1, y: "-1/2" }}
exit={{ opacity: 0, scale: 0.9, y: "-1/2" }}
transition={Spring.presets.smooth}
className="absolute right-1 top-0 -translate-y-1/2 rounded-lg border border-gray-200 bg-white/90 p-1 shadow-sm backdrop-blur-sm dark:border-neutral-900 dark:bg-neutral-900"
>
<div className="flex items-center gap-1">
<EntryHeaderActions entryId={entryId} view={FeedViewType.SocialMedia} />
<MoreActions entryId={entryId} view={FeedViewType.SocialMedia} />
</div>
</m.div>
)
}
SocialMediaItem.wrapperClassName = readableContentMaxWidth
export function SocialMediaItemStateLess({ entry, feed }: EntryItemStatelessProps) {
return (

View File

@ -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 <SocialMediaDateItem date={date} className={dateItemclassName} isSticky={isSticky} />
@ -92,7 +92,7 @@ const DateItemInner: FC<DateItemInnerProps> = ({
)
return (
<div
className={cn(className, isSticky && "border-b")}
className={cn(className, isSticky && "bg-background border-b")}
onClick={stopPropagation}
onMouseEnter={removeConfirm.cancel}
onMouseLeave={removeConfirm}

View File

@ -56,7 +56,6 @@ export const VirtualRowItem: FC<VirtualRowItemProps> = memo(
{isStickyItem && (
<div
className={clsx(
"bg-background",
isActiveStickyItem
? "sticky top-0 z-[1]"
: "absolute left-0 top-0 z-[1] w-full will-change-transform",
@ -83,7 +82,7 @@ export const VirtualRowItem: FC<VirtualRowItemProps> = memo(
style={useMemo(
() => ({
transform,
paddingTop: isStickyItem ? "1.75rem" : undefined,
paddingTop: isStickyItem ? "2.75rem" : undefined,
}),
[transform, isStickyItem],
)}

View File

@ -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<HTMLAnchorElement> | MouseEvent<HTMLAnchorElement>) => {
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 (
<div data-entry-id={entry?.id} style={style}>
<NavLink
to={navigationPath}
className={cn(
"hover:bg-theme-item-hover cursor-button relative block duration-200",
views[view as FeedViewType]?.wideMode ? "rounded-md" : "px-2",
isWide ? "rounded-md" : "",
(isActive || isContextMenuOpen) && "!bg-theme-item-active",
itemClassName,
)}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseEnter.cancel}
onMouseLeave={handleMouseLeave}
onDoubleClick={handleDoubleClick}
{...contextMenuProps}
{...(!isMobile ? { onTouchStart: handleClick } : {})}
>
{children}
<AnimatePresence>{showAction && isWide && <ActionBar entryId={entryId} />}</AnimatePresence>
</NavLink>
</div>
)
}
const ActionBar = ({ entryId }: { entryId: string }) => {
const { mainAction: entryActions } = useSortedEntryActions({
entryId,
view: FeedViewType.SocialMedia,
})
const { view } = useRouteParams()
if (entryActions.length === 0) return null
return (
<m.div
initial={{ opacity: 0, scale: 0.9, y: "-1/2" }}
animate={{ opacity: 1, scale: 1, y: "-1/2" }}
exit={{ opacity: 0, scale: 0.9, y: "-1/2" }}
transition={Spring.presets.smooth}
className="absolute right-1 top-0 -translate-y-1/2 rounded-lg border border-gray-200 bg-white/90 p-1 shadow-sm backdrop-blur-sm dark:border-neutral-900 dark:bg-neutral-900"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-1">
<EntryHeaderActions entryId={entryId} view={view} compact />
<MoreActions entryId={entryId} view={view} compact />
</div>
</m.div>
)
}

View File

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

View File

@ -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 (
<div
className={cn(
"cursor-menu group relative mb-4 flex py-4",
!isRead &&
"before:bg-accent before:absolute before:-left-3 before:top-6 before:block before:size-2 before:rounded-full",
)}
>
<FeedIcon feed={related} fallback entry={entry?.iconEntry} size={24} />
<div
className={cn("-mt-0.5 ml-1 h-fit flex-1 text-sm leading-tight", lineClamp.global)}
style={{
maxWidth: `calc(100% - ${savedWidth}px)`,
}}
>
<div
className={cn(
"flex gap-1 text-[10px] font-bold",
"text-text-secondary",
isInCollection && "text-text-secondary",
isRead && dimRead && "text-text-tertiary",
)}
>
<EllipsisHorizontalTextWithTooltip className="truncate">
<FeedTitle
feed={related}
title={getPreferredTitle(related, entry?.titleEntry)}
className="space-x-0.5"
/>
</EllipsisHorizontalTextWithTooltip>
<span>·</span>
<span className="shrink-0">{!!displayTime && <RelativeTime date={displayTime} />}</span>
</div>
<div
className={cn(
"relative my-0.5 break-words",
"text-text",
!!isInCollection && "pr-5",
entry?.title ? "font-medium" : "text-[13px]",
isRead && dimRead && "text-text-secondary",
)}
>
{entry?.title ? (
<EntryTranslation
className={cn("hyphens-auto font-medium", lineClamp.title)}
source={titleCase(entry?.title ?? "")}
target={titleCase(translation?.title ?? "")}
/>
) : (
<EntryTranslation
className={cn("hyphens-auto", lineClamp.description)}
source={entry?.description}
target={translation?.description}
/>
)}
{!!isInCollection && <StarIcon className="absolute right-0 top-0" />}
</div>
{!simple && (
<div
className={cn(
"text-[13px]",
"text-text-secondary",
isRead && dimRead && "text-text-tertiary",
)}
>
<EntryTranslation
className={cn("hyphens-auto", lineClamp.description)}
source={entry?.description}
target={translation?.description}
/>
</div>
)}
</div>
{hasAudio && entry.firstAudio && (
<AudioCover
entryId={entryId}
src={entry.firstAudio.url}
durationInSeconds={entry.firstAudio.duration_in_seconds}
feedIcon={
<FeedIcon
fallback={true}
fallbackElement={
<div className={clsx("bg-material-ultra-thick", "size-[80px]", "rounded")} />
}
feed={feed || inbox}
entry={entry?.iconEntry}
size={80}
className="m-0 rounded"
useMedia
noMargin
/>
}
/>
)}
{!simple && !hasAudio && entry.firstMedia && (
<Media
thumbnail
src={entry.firstMedia.url}
type={entry.firstMedia.type}
previewImageUrl={entry.firstMedia.preview_image_url}
className={cn("center ml-2 flex shrink-0 rounded", "size-20")}
mediaContainerClassName={"w-auto h-auto rounded"}
loading="lazy"
key={`${rid}-media-${thumbnailRatio}`}
proxy={{
width: 160,
height: thumbnailRatio === "square" ? 160 : 0,
}}
height={entry.firstMedia.height}
width={entry.firstMedia.width}
blurhash={entry.firstMedia.blurhash}
/>
)}
</div>
)
}
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<HTMLDivElement>) => {
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 (
<div className="relative ml-2 shrink-0">
{feedIcon}
<div
className={cn(
"center absolute inset-0 w-full transition-all duration-200 ease-in-out group-hover:-translate-y-2 group-hover:opacity-100",
playStatus || isMobile ? "-translate-y-2 opacity-100" : "opacity-0",
)}
onClick={handleClickPlay}
>
<button
type="button"
className="center bg-material-opaque hover:bg-accent size-10 rounded-full opacity-95 hover:text-white hover:opacity-100"
>
<i
className={cn("size-6", {
"i-mingcute-pause-fill": playStatus && playStatus === "playing",
"i-mingcute-loading-fill animate-spin": playStatus && playStatus === "loading",
"i-mingcute-play-fill": !playStatus || playStatus === "paused",
})}
/>
</button>
</div>
{!!estimatedMins && (
<div className="absolute bottom-0 w-full overflow-hidden rounded-b-sm text-center">
<div
className={cn(
"bg-material-ultra-thick absolute left-0 top-0 size-full opacity-0 duration-200 group-hover:opacity-100",
isMobile && "opacity-100",
)}
/>
<div
className={cn(
"group-hover:backdrop-blur-background text-body opacity-0 backdrop-blur-none duration-200 group-hover:opacity-100",
isMobile && "backdrop-blur-background opacity-100",
)}
>
{isChinese ? `${estimatedMins} 分钟` : formatEstimatedMins(estimatedMins)}
</div>
</div>
)}
</div>
)
}

View File

@ -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 (
<div
className={cn(
"cursor-menu group relative flex",
!isRead &&
"before:bg-accent before:absolute before:-left-3 before:top-[1.4375rem] before:block before:size-2 before:rounded-full",
settingWideMode ? "py-3" : "py-4",
)}
>
<FeedIcon feed={related} fallback entry={entry?.iconEntry} />
<div
className={cn("-mt-0.5 flex-1 text-sm leading-tight", lineClamp.global)}
style={{
maxWidth: `calc(100% - ${savedWidth}px)`,
}}
>
<div
className={cn(
"flex gap-1 text-[10px] font-bold",
"text-text-secondary",
isInCollection && "text-text-secondary",
isRead && dimRead && "text-text-tertiary",
)}
>
<EllipsisHorizontalTextWithTooltip className="truncate">
<FeedTitle
feed={related}
title={getPreferredTitle(related, entry?.titleEntry)}
className="space-x-0.5"
/>
</EllipsisHorizontalTextWithTooltip>
<span>·</span>
<span className="shrink-0">{!!displayTime && <RelativeTime date={displayTime} />}</span>
</div>
<div
className={cn(
"relative my-0.5 break-words",
"text-text",
!!isInCollection && "pr-5",
entry?.title ? "font-medium" : "text-[13px]",
isRead && dimRead && "text-text-secondary",
)}
>
{entry?.title ? (
<EntryTranslation
className={cn("hyphens-auto font-medium", lineClamp.title)}
source={titleCase(entry?.title ?? "")}
target={titleCase(translation?.title ?? "")}
/>
) : (
<EntryTranslation
className={cn("hyphens-auto", lineClamp.description)}
source={entry?.description}
target={translation?.description}
/>
)}
{!!isInCollection && <StarIcon className="absolute right-0 top-0" />}
</div>
{!simple && (
<div
className={cn(
"text-[13px]",
"text-text-secondary",
isRead && dimRead && "text-text-tertiary",
)}
>
<EntryTranslation
className={cn("hyphens-auto", lineClamp.description)}
source={entry?.description}
target={translation?.description}
/>
</div>
)}
</div>
{hasAudio && entry.firstAudio && (
<AudioCover
entryId={entryId}
src={entry.firstAudio.url}
durationInSeconds={entry.firstAudio.duration_in_seconds}
feedIcon={
<FeedIcon
fallback={true}
fallbackElement={
<div
className={clsx(
"bg-material-ultra-thick",
settingWideMode ? "size-[65px]" : "size-[80px]",
"rounded",
)}
/>
}
feed={feed || inbox}
entry={entry?.iconEntry}
size={settingWideMode ? 65 : 80}
className="m-0 rounded"
useMedia
noMargin
/>
}
/>
)}
{!simple && !hasAudio && entry.firstMedia && (
<Media
thumbnail
src={entry.firstMedia.url}
type={entry.firstMedia.type}
previewImageUrl={entry.firstMedia.preview_image_url}
className={cn(
"center ml-2 flex shrink-0 rounded",
settingWideMode ? "size-12" : "size-20",
)}
mediaContainerClassName={"w-auto h-auto rounded"}
loading="lazy"
key={`${rid}-media-${thumbnailRatio}`}
proxy={{
width: 160,
height: thumbnailRatio === "square" ? 160 : 0,
}}
height={entry.firstMedia.height}
width={entry.firstMedia.width}
blurhash={entry.firstMedia.blurhash}
/>
)}
</div>
)
}
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<HTMLDivElement>) => {
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 (
<div className="relative ml-2 shrink-0">
{feedIcon}
<div
className={cn(
"center absolute inset-0 w-full transition-all duration-200 ease-in-out group-hover:-translate-y-2 group-hover:opacity-100",
playStatus || isMobile ? "-translate-y-2 opacity-100" : "opacity-0",
)}
onClick={handleClickPlay}
>
<button
type="button"
className="center bg-material-opaque hover:bg-accent size-10 rounded-full opacity-95 hover:text-white hover:opacity-100"
>
<i
className={cn("size-6", {
"i-mingcute-pause-fill": playStatus && playStatus === "playing",
"i-mingcute-loading-fill animate-spin": playStatus && playStatus === "loading",
"i-mingcute-play-fill": !playStatus || playStatus === "paused",
})}
/>
</button>
</div>
{!!estimatedMins && (
<div className="absolute bottom-0 w-full overflow-hidden rounded-b-sm text-center">
<div
className={cn(
"bg-material-ultra-thick absolute left-0 top-0 size-full opacity-0 duration-200 group-hover:opacity-100",
isMobile && "opacity-100",
)}
/>
<div
className={cn(
"group-hover:backdrop-blur-background text-body opacity-0 backdrop-blur-none duration-200 group-hover:opacity-100",
isMobile && "backdrop-blur-background opacity-100",
)}
>
{isChinese ? `${estimatedMins} 分钟` : formatEstimatedMins(estimatedMins)}
</div>
</div>
)}
</div>
)
}

View File

@ -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 (
<div
className={cn(
"cursor-menu group relative flex pl-3 pr-2",
!isRead &&
"before:bg-accent before:absolute before:-left-0.5 before:top-[1.4375rem] before:block before:size-2 before:rounded-full",
settingWideMode ? "py-3" : "py-4",
)}
>
<FeedIcon feed={related} fallback entry={entry?.iconEntry} />
<div
className={cn("-mt-0.5 flex-1 text-sm leading-tight", lineClamp.global)}
style={{
maxWidth: `calc(100% - ${savedWidth}px)`,
}}
>
<div
className={cn(
"flex gap-1 text-[10px] font-bold",
"text-text-secondary",
isInCollection && "text-text-secondary",
isRead && dimRead && "text-text-tertiary",
)}
>
<EllipsisHorizontalTextWithTooltip className="truncate">
<FeedTitle
feed={related}
title={getPreferredTitle(related, entry?.titleEntry)}
className="space-x-0.5"
/>
</EllipsisHorizontalTextWithTooltip>
<span>·</span>
<span className="shrink-0">{!!displayTime && <RelativeTime date={displayTime} />}</span>
</div>
<div
className={cn(
"relative my-0.5 break-words",
"text-text",
!!isInCollection && "pr-5",
entry?.title ? "font-medium" : "text-[13px]",
isRead && dimRead && "text-text-secondary",
)}
>
{entry?.title ? (
<EntryTranslation
className={cn("hyphens-auto font-medium", lineClamp.title)}
source={titleCase(entry?.title ?? "")}
target={titleCase(translation?.title ?? "")}
/>
) : (
<EntryTranslation
className={cn("hyphens-auto", lineClamp.description)}
source={entry?.description}
target={translation?.description}
/>
)}
{!!isInCollection && <StarIcon className="absolute right-0 top-0" />}
</div>
{!simple && (
<div
className={cn(
"text-[13px]",
"text-text-secondary",
isRead && dimRead && "text-text-tertiary",
)}
>
<EntryTranslation
className={cn("hyphens-auto", lineClamp.description)}
source={entry?.description}
target={translation?.description}
/>
</div>
)}
</div>
{hasAudio && entry.firstAudio && (
<AudioCover
entryId={entryId}
src={entry.firstAudio.url}
durationInSeconds={entry.firstAudio.duration_in_seconds}
feedIcon={
<FeedIcon
fallback={true}
fallbackElement={
<div
className={clsx(
"bg-material-ultra-thick",
settingWideMode ? "size-[65px]" : "size-[80px]",
"rounded",
)}
/>
}
feed={feed || inbox}
entry={entry?.iconEntry}
size={settingWideMode ? 65 : 80}
className="m-0 rounded"
useMedia
noMargin
/>
}
/>
)}
{!simple && !hasAudio && entry.firstMedia && (
<Media
thumbnail
src={entry.firstMedia.url}
type={entry.firstMedia.type}
previewImageUrl={entry.firstMedia.preview_image_url}
className={cn(
"center ml-2 flex shrink-0 rounded",
settingWideMode ? "size-12" : "size-20",
)}
mediaContainerClassName={"w-auto h-auto rounded"}
loading="lazy"
key={`${rid}-media-${thumbnailRatio}`}
proxy={{
width: 160,
height: thumbnailRatio === "square" ? 160 : 0,
}}
height={entry.firstMedia.height}
width={entry.firstMedia.width}
blurhash={entry.firstMedia.blurhash}
/>
)}
</div>
)
}
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<HTMLDivElement>) => {
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 (
<div className="relative ml-2 shrink-0">
{feedIcon}
<div
className={cn(
"center absolute inset-0 w-full transition-all duration-200 ease-in-out group-hover:-translate-y-2 group-hover:opacity-100",
playStatus || isMobile ? "-translate-y-2 opacity-100" : "opacity-0",
)}
onClick={handleClickPlay}
>
<button
type="button"
className="center bg-material-opaque hover:bg-accent size-10 rounded-full opacity-95 hover:text-white hover:opacity-100"
>
<i
className={cn("size-6", {
"i-mingcute-pause-fill": playStatus && playStatus === "playing",
"i-mingcute-loading-fill animate-spin": playStatus && playStatus === "loading",
"i-mingcute-play-fill": !playStatus || playStatus === "paused",
})}
/>
</button>
</div>
{!!estimatedMins && (
<div className="absolute bottom-0 w-full overflow-hidden rounded-b-sm text-center">
<div
className={cn(
"bg-material-ultra-thick absolute left-0 top-0 size-full opacity-0 duration-200 group-hover:opacity-100",
isMobile && "opacity-100",
)}
/>
<div
className={cn(
"group-hover:backdrop-blur-background text-body opacity-0 backdrop-blur-none duration-200 group-hover:opacity-100",
isMobile && "backdrop-blur-background opacity-100",
)}
>
{isChinese ? `${estimatedMins} 分钟` : formatEstimatedMins(estimatedMins)}
</div>
</div>
)}
</div>
)
}
export const ListItem = withFeature("ai")(ListItemAI, ListItemLegacy)

View File

@ -30,6 +30,7 @@ export const EntryHeaderActions = ({
clickableDisabled={config.disabled}
highlightMotion={config.notice}
id={`${config.entryId}/${config.id}`}
size={compact ? "sm" : "base"}
/>
)
})

View File

@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<ActionButton icon={<i className="i-mgc-more-1-cute-re" />} />
<ActionButton
icon={<i className="i-mgc-more-1-cute-re" />}
size={compact ? "sm" : "base"}
/>
</DropdownMenuTrigger>
<RootPortal>
<DropdownMenuContent>

View File

@ -255,6 +255,7 @@ const EntryScrollArea: Component<{
<ScrollArea.ScrollArea
focusable
mask={false}
stopWheelPropagation={false}
rootClassName={cn(
"h-0 min-w-0 grow overflow-y-auto print:h-auto print:overflow-visible",
className,

View File

@ -9,10 +9,10 @@ import { cn } from "@follow/utils/utils"
import { useStore } from "jotai"
import { memo, useEffect, useMemo, useState } from "react"
import { useServerConfigs } from "~/atoms/server-configs"
import { useAIChatPinned } from "~/atoms/settings/ai"
import type { TocRef } from "~/components/ui/markdown/components/Toc"
import { Toc } from "~/components/ui/markdown/components/Toc"
import { useFeature } from "~/hooks/biz/useFeature"
import { useWrappedElement, useWrappedElementSize } from "~/providers/wrapped-element-provider"
const useReadPercent = () => {
@ -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()

View File

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

1
icons/mgc/up_cute_re.svg Normal file
View File

@ -0,0 +1 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M11.476 8.258c-.271.075-.597.261-1.156.662a17.374 17.374 0 0 0-4.461 4.642c-.438.665-.497.795-.498 1.1-.003.748.766 1.211 1.442.868.186-.094.251-.161.455-.47.132-.198.382-.567.558-.82 1.015-1.466 2.518-2.949 3.954-3.899l.23-.152.23.152c1.436.95 2.939 2.433 3.954 3.899.176.253.426.622.558.82.204.309.269.376.455.47.676.343 1.445-.12 1.442-.868-.001-.305-.06-.435-.498-1.1A17.374 17.374 0 0 0 13.68 8.92c-.575-.412-.884-.587-1.175-.664-.252-.068-.781-.066-1.029.002" fill="#10161F" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 612 B

View File

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

View File

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

View File

@ -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<HTMLDivElement | null> }) => {
const [viewportRef, setViewportRef] = React.useState<HTMLDivElement | null>(null)
React.useImperativeHandle(ref, () => viewportRef as HTMLDivElement)
@ -169,7 +171,7 @@ export const ScrollArea = ({
<Root className={rootClassName}>
<Viewport
ref={setViewportRef}
onWheel={stopPropagation}
onWheel={stopWheelPropagation ? stopPropagation : undefined}
className={cn(flex ? "[&>div]:!flex [&>div]:!flex-col" : "", viewportClassName)}
mask={mask}
asChild={asChild}

View File

@ -49,6 +49,7 @@ export const defaultUISettings: UISettings = {
// Sidebar
entryColWidth: 356,
aiColWidth: 384,
feedColWidth: 256,
hideExtraBadge: false,

View File

@ -51,6 +51,7 @@ export type AccentColor =
export interface UISettings {
accentColor: AccentColor
entryColWidth: number
aiColWidth: number
feedColWidth: number
opaqueSidebar: boolean
sidebarShowUnreadCount: boolean

View File

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