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:
parent
d79b00d976
commit
0e456d5b73
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"), {}),
|
||||
)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export const EntryHeaderActions = ({
|
|||
clickableDisabled={config.disabled}
|
||||
highlightMotion={config.notice}
|
||||
id={`${config.entryId}/${config.id}`}
|
||||
size={compact ? "sm" : "base"}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export const defaultUISettings: UISettings = {
|
|||
|
||||
// Sidebar
|
||||
entryColWidth: 356,
|
||||
aiColWidth: 384,
|
||||
feedColWidth: 256,
|
||||
hideExtraBadge: false,
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ export type AccentColor =
|
|||
export interface UISettings {
|
||||
accentColor: AccentColor
|
||||
entryColWidth: number
|
||||
aiColWidth: number
|
||||
feedColWidth: number
|
||||
opaqueSidebar: boolean
|
||||
sidebarShowUnreadCount: boolean
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue