feat: entry content layout for ai

This commit is contained in:
DIYgod 2025-07-23 22:11:18 +08:00
parent 8b76fa3623
commit c8e3a0e1d4
No known key found for this signature in database
24 changed files with 1811 additions and 629 deletions

View File

@ -27,7 +27,7 @@ export function SwipeMedia({
proxySize?: {
width: number
height: number
}
} | null
}) {
const uniqMedia = media ? uniqBy(media, "url") : []
@ -60,19 +60,21 @@ export function SwipeMedia({
{uniqMedia?.slice(0, 5).map((med, i) => (
<div className="mr-2 size-full flex-none" key={med.url}>
<Media
className={cn(imgClassName, "size-full rounded-none")}
mediaContainerClassName="object-cover"
className="size-full rounded-none"
mediaContainerClassName={cn("object-cover", imgClassName)}
alt="cover"
cacheDimensions={med.type === "photo"}
src={med.url}
type={med.type}
previewImageUrl={med.preview_image_url}
loading="lazy"
proxy={proxySize}
proxy={proxySize || undefined}
blurhash={med.blurhash}
onClick={(e) => {
e.stopPropagation()
onPreview?.(uniqMedia, i)
if (onPreview) {
e.stopPropagation()
onPreview(uniqMedia, i)
}
}}
showFallback={true}
fitContent

View File

@ -1,2 +1,4 @@
export const ElECTRON_CUSTOM_TITLEBAR_HEIGHT = 30
// export const ELECTRON_WINDOWS_RADIUS = 12
export const readableContentMaxWidthClassName = "max-w-[clamp(45ch,60vw,65ch)]"

View File

@ -171,7 +171,7 @@ export const EntryColumnLayout = () => {
</div>
</Button>
</div>
<EntryContent entryId={realEntryId} className="h-full" />
<EntryContent entryId={realEntryId} className="h-[calc(100%-2.25rem)]" />
</m.div>
)}
</AnimatePresence>

View File

@ -12,6 +12,7 @@ import { memo, use, useEffect, useMemo, useState } from "react"
import { useTranslation } from "react-i18next"
import { SwipeMedia } from "~/components/ui/media/SwipeMedia"
import { useFeature } from "~/hooks/biz/useFeature"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { EntryContent } from "~/modules/entry-content/components/entry-content"
import { useImageDimensions } from "~/store/image"
@ -43,9 +44,7 @@ export function PictureItem({ entryId, entryPreview, translation }: UniversalIte
isActive && "rounded-b-none",
)}
imgClassName="object-cover"
onPreview={(media, i) => {
previewMedia(media, i)
}}
onPreview={previewMedia}
/>
) : (
<div className="center bg-material-medium text-text-secondary aspect-square w-full flex-col gap-1 rounded-md text-xs">
@ -75,6 +74,7 @@ export const PictureWaterFallItem = memo(function PictureWaterFallItem({
id: state.id,
}))
const aiEnabled = useFeature("ai")
const isActive = useRouteParamsSelector(({ entryId }) => entryId === entry?.id)
const entryContent = useMemo(() => <EntryContent entryId={entryId} noMedia compact />, [entryId])
const previewMedia = usePreviewMedia(entryContent)
@ -118,7 +118,7 @@ export const PictureWaterFallItem = memo(function PictureWaterFallItem({
)}
proxySize={proxySize}
imgClassName="object-cover"
onPreview={previewMedia}
onPreview={aiEnabled ? undefined : previewMedia}
/>
<div className="z-[3] shrink-0 overflow-hidden rounded-b-md pb-1">

View File

@ -209,7 +209,7 @@ export const SocialMediaItemSkeleton = (
</div>
)
const SocialMediaGallery = ({ entryId }: { entryId: string }) => {
export const SocialMediaGallery = ({ entryId }: { entryId: string }) => {
const entry = useEntry(entryId, (state) => ({ media: state.media }))
const media = useMemo(() => entry?.media || [], [entry?.media])

View File

@ -0,0 +1,198 @@
import { Skeleton } from "@follow/components/ui/skeleton/index.jsx"
import { IN_ELECTRON } from "@follow/shared/constants"
import { useEntry } from "@follow/store/entry/hooks"
import { formatDuration } from "@follow/utils/duration"
import { transformVideoUrl } from "@follow/utils/url-for-video"
import { cn } from "@follow/utils/utils"
import { useHover } from "@use-gesture/react"
import { useEffect, useMemo, useRef, useState } from "react"
import { RelativeTime } from "~/components/ui/datetime"
import { Media } from "~/components/ui/media/Media"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { FeedIcon } from "~/modules/feed/feed-icon"
import { FeedTitle } from "~/modules/feed/feed-title"
import { GridItem } from "../templates/grid-item-template"
import type { EntryItemStatelessProps, UniversalItemProps } from "../types"
const ViewTag = IN_ELECTRON ? "webview" : "iframe"
export function VideoItem({ entryId, entryPreview, translation }: UniversalItemProps) {
const entry = useEntry(entryId, (state) => {
const { id, url } = state
const attachments = state.attachments || []
const { duration_in_seconds } =
attachments?.find((attachment) => attachment.duration_in_seconds) ?? {}
const seconds = duration_in_seconds
? Number.parseInt(duration_in_seconds.toString())
: undefined
const duration = formatDuration(seconds)
const media = state.media || []
const firstMedia = media[0]
return { attachments, duration, firstMedia, id, url, media }
})
const isActive = useRouteParamsSelector(({ entryId }) => entryId === entry?.id)
const [miniIframeSrc] = useMemo(
() => [
transformVideoUrl({
url: entry?.url ?? "",
mini: true,
isIframe: !IN_ELECTRON,
attachments: entry?.attachments,
}),
transformVideoUrl({
url: entry?.url ?? "",
isIframe: !IN_ELECTRON,
attachments: entry?.attachments,
}),
],
[entry?.attachments, entry?.url],
)
const ref = useRef<HTMLDivElement>(null)
const [hovered, setHovered] = useState(false)
useHover(
(event) => {
setHovered(event.active)
},
{
target: ref,
},
)
const [showPreview, setShowPreview] = useState(false)
useEffect(() => {
if (hovered) {
const timer = setTimeout(() => {
setShowPreview(true)
}, 500)
return () => clearTimeout(timer)
} else {
setShowPreview(false)
return () => {}
}
}, [hovered])
if (!entry) return null
return (
<GridItem entryId={entryId} entryPreview={entryPreview} translation={translation}>
<div className="cursor-card w-full">
<div className="relative overflow-x-auto" ref={ref}>
{miniIframeSrc && showPreview ? (
<ViewTag
src={miniIframeSrc}
className={cn(
"pointer-events-none aspect-video w-full shrink-0 rounded-md bg-black object-cover",
isActive && "rounded-b-none",
)}
/>
) : entry.firstMedia ? (
<Media
key={entry.firstMedia.url}
src={entry.firstMedia.url}
type={entry.firstMedia.type}
previewImageUrl={entry.firstMedia.preview_image_url}
className={cn(
"aspect-video w-full shrink-0 rounded-md object-cover",
isActive && "rounded-b-none",
)}
loading="lazy"
proxy={{
width: 640,
height: 360,
}}
showFallback={true}
/>
) : (
<div className="center bg-material-medium text-text-secondary aspect-video w-full flex-col gap-1 rounded-md text-xs">
<i className="i-mgc-sad-cute-re size-6" />
No media available
</div>
)}
{!!entry.duration && (
<div className="absolute bottom-2 right-2 rounded-md bg-black/50 px-1 py-0.5 text-xs font-medium text-white">
{entry.duration}
</div>
)}
</div>
</div>
</GridItem>
)
}
export function VideoItemStateLess({ entry, feed }: EntryItemStatelessProps) {
return (
<div className="text-text relative mx-auto w-full max-w-lg rounded-md transition-colors">
<div className="relative">
<div className="p-1.5">
<div className="w-full">
<div className="overflow-x-auto">
{entry.media?.[0] ? (
<Media
thumbnail
src={entry.media[0].url}
type={entry.media[0].type}
previewImageUrl={entry.media[0].preview_image_url}
className="aspect-video w-full shrink-0 overflow-hidden"
mediaContainerClassName={"w-auto h-auto rounded"}
loading="lazy"
proxy={{
width: 0,
height: 0,
}}
height={entry.media[0].height}
width={entry.media[0].width}
blurhash={entry.media[0].blurhash}
/>
) : (
<Skeleton className="aspect-video w-full shrink-0 overflow-hidden" />
)}
</div>
</div>
<div className="relative flex-1 px-2 pb-3 pt-1 text-sm">
<div className="relative mb-1 mt-1.5 truncate font-medium leading-none">
{entry.title}
</div>
<div className="text-text-secondary mt-1 flex items-center gap-1 truncate text-[13px]">
<FeedIcon feed={feed} fallback className="size-4" />
<FeedTitle feed={feed} />
<span className="text-material-opaque">·</span>
{!!entry.publishedAt && <RelativeTime date={entry.publishedAt} />}
</div>
</div>
</div>
</div>
</div>
)
}
export const VideoItemSkeleton = (
<div className="relative mx-auto w-full max-w-lg rounded-md">
<div className="relative">
<div className="p-1.5">
<div className="w-full">
<div className="overflow-x-auto">
<Skeleton className="aspect-video w-full shrink-0 overflow-hidden" />
</div>
</div>
<div className="relative flex-1 px-2 pb-3 pt-1 text-sm">
<div className="relative mb-1 mt-1.5 truncate font-medium leading-none">
<Skeleton className="h-4 w-3/4" />
</div>
<div className="mt-1 flex items-center gap-1 truncate text-[13px]">
<Skeleton className="mr-0.5 size-4" />
<Skeleton className="h-3 w-1/2" />
<span className="text-material-opaque">·</span>
<Skeleton className="h-3 w-12" />
</div>
</div>
</div>
</div>
</div>
)

View File

@ -0,0 +1,304 @@
import { isMobile } from "@follow/components/hooks/useMobile.js"
import { Skeleton } from "@follow/components/ui/skeleton/index.jsx"
import { IN_ELECTRON } from "@follow/shared/constants"
import { useEntry } from "@follow/store/entry/hooks"
import { useEntryTranslation, usePrefetchEntryTranslation } from "@follow/store/translation/hooks"
import { stopPropagation } from "@follow/utils/dom"
import { formatDuration } from "@follow/utils/duration"
import { transformVideoUrl } from "@follow/utils/url-for-video"
import { cn } from "@follow/utils/utils"
import { useHover } from "@use-gesture/react"
import { useEffect, useMemo, useRef, useState } from "react"
import { AudioPlayer } from "~/atoms/player"
import { useActionLanguage, useGeneralSettingKey } from "~/atoms/settings/general"
import { m } from "~/components/common/Motion"
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 type { ModalContentComponent } from "~/components/ui/modal"
import { FixedModalCloseButton } from "~/components/ui/modal/components/close"
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { checkLanguage } from "~/lib/translate"
import { EntryContent } from "~/modules/entry-content/components/entry-content/EntryContent"
import { FeedIcon } from "~/modules/feed/feed-icon"
import { FeedTitle } from "~/modules/feed/feed-title"
import { GridItem } from "../templates/grid-item-template"
import type { EntryItemStatelessProps, UniversalItemProps } from "../types"
const ViewTag = IN_ELECTRON ? "webview" : "iframe"
export function VideoItem({ entryId, entryPreview, translation }: UniversalItemProps) {
const entry = useEntry(entryId, (state) => {
const { id, url } = state
const attachments = state.attachments || []
const { duration_in_seconds } =
attachments?.find((attachment) => attachment.duration_in_seconds) ?? {}
const seconds = duration_in_seconds
? Number.parseInt(duration_in_seconds.toString())
: undefined
const duration = formatDuration(seconds)
const media = state.media || []
const firstMedia = media[0]
return { attachments, duration, firstMedia, id, url, media }
})
const isActive = useRouteParamsSelector(({ entryId }) => entryId === entry?.id)
const [miniIframeSrc, iframeSrc] = useMemo(
() => [
transformVideoUrl({
url: entry?.url ?? "",
mini: true,
isIframe: !IN_ELECTRON,
attachments: entry?.attachments,
}),
transformVideoUrl({
url: entry?.url ?? "",
isIframe: !IN_ELECTRON,
attachments: entry?.attachments,
}),
],
[entry?.attachments, entry?.url],
)
const modalStack = useModalStack()
const entryContent = useMemo(() => <EntryContent entryId={entryId} noMedia compact />, [entryId])
const previewMedia = usePreviewMedia(entryContent)
const ref = useRef<HTMLDivElement>(null)
const [hovered, setHovered] = useState(false)
useHover(
(event) => {
setHovered(event.active)
},
{
target: ref,
},
)
const [showPreview, setShowPreview] = useState(false)
useEffect(() => {
if (hovered) {
const timer = setTimeout(() => {
setShowPreview(true)
}, 500)
return () => clearTimeout(timer)
} else {
setShowPreview(false)
return () => {}
}
}, [hovered])
if (!entry) return null
return (
<GridItem entryId={entryId} entryPreview={entryPreview} translation={translation}>
<div
className="cursor-card w-full"
onClick={(e) => {
if (isMobile() && entry.url) {
window.open(entry.url, "_blank")
e.stopPropagation()
return
}
if (iframeSrc) {
modalStack.present({
title: "",
content: (props) => (
<PreviewVideoModalContent src={iframeSrc} entryId={entryId} {...props} />
),
clickOutsideToDismiss: true,
CustomModalComponent: PlainModal,
overlay: true,
})
} else {
previewMedia(entry.media)
}
}}
>
<div className="relative overflow-x-auto" ref={ref}>
{miniIframeSrc && showPreview ? (
<ViewTag
src={miniIframeSrc}
className={cn(
"pointer-events-none aspect-video w-full shrink-0 rounded-md bg-black object-cover",
isActive && "rounded-b-none",
)}
/>
) : entry.firstMedia ? (
<Media
key={entry.firstMedia.url}
src={entry.firstMedia.url}
type={entry.firstMedia.type}
previewImageUrl={entry.firstMedia.preview_image_url}
className={cn(
"aspect-video w-full shrink-0 rounded-md object-cover",
isActive && "rounded-b-none",
)}
loading="lazy"
proxy={{
width: 640,
height: 360,
}}
showFallback={true}
/>
) : (
<div className="center bg-material-medium text-text-secondary aspect-video w-full flex-col gap-1 rounded-md text-xs">
<i className="i-mgc-sad-cute-re size-6" />
No media available
</div>
)}
{!!entry.duration && (
<div className="absolute bottom-2 right-2 rounded-md bg-black/50 px-1 py-0.5 text-xs font-medium text-white">
{entry.duration}
</div>
)}
</div>
</div>
</GridItem>
)
}
const PreviewVideoModalContent: ModalContentComponent<{
src: string
entryId: string
}> = ({ dismiss, src, entryId }) => {
const entry = useEntry(entryId, (state) => ({ content: state.content }))
const enableTranslation = useGeneralSettingKey("translation")
const actionLanguage = useActionLanguage()
const translation = useEntryTranslation({
entryId,
language: actionLanguage,
setting: enableTranslation,
})
usePrefetchEntryTranslation({
entryIds: [entryId],
checkLanguage,
setting: enableTranslation,
language: actionLanguage,
withContent: true,
})
const content = translation?.content || entry?.content
const currentAudioPlayerIsPlay = useRef(AudioPlayer.get().status === "playing")
const renderStyle = useRenderStyle()
useEffect(() => {
const currentValue = currentAudioPlayerIsPlay.current
if (currentValue) {
AudioPlayer.pause()
}
return () => {
if (currentValue) {
AudioPlayer.play()
}
}
}, [])
return (
<m.div exit={{ scale: 0.94, opacity: 0 }} className="size-full p-12" onClick={() => dismiss()}>
<m.div
onFocusCapture={stopPropagation}
initial={true}
exit={{
opacity: 0,
}}
className="safe-inset-top-4 fixed right-4 flex items-center"
>
<FixedModalCloseButton onClick={dismiss} />
</m.div>
<ViewTag src={src} className="size-full" />
{!!content && (
<div className="bg-background p-10 pt-5 backdrop-blur-sm">
<HTML
as="div"
className="prose dark:prose-invert !max-w-full"
noMedia
style={renderStyle}
>
{content}
</HTML>
</div>
)}
</m.div>
)
}
export function VideoItemStateLess({ entry, feed }: EntryItemStatelessProps) {
return (
<div className="text-text relative mx-auto w-full max-w-lg rounded-md transition-colors">
<div className="relative">
<div className="p-1.5">
<div className="w-full">
<div className="overflow-x-auto">
{entry.media?.[0] ? (
<Media
thumbnail
src={entry.media[0].url}
type={entry.media[0].type}
previewImageUrl={entry.media[0].preview_image_url}
className="aspect-video w-full shrink-0 overflow-hidden"
mediaContainerClassName={"w-auto h-auto rounded"}
loading="lazy"
proxy={{
width: 0,
height: 0,
}}
height={entry.media[0].height}
width={entry.media[0].width}
blurhash={entry.media[0].blurhash}
/>
) : (
<Skeleton className="aspect-video w-full shrink-0 overflow-hidden" />
)}
</div>
</div>
<div className="relative flex-1 px-2 pb-3 pt-1 text-sm">
<div className="relative mb-1 mt-1.5 truncate font-medium leading-none">
{entry.title}
</div>
<div className="text-text-secondary mt-1 flex items-center gap-1 truncate text-[13px]">
<FeedIcon feed={feed} fallback className="size-4" />
<FeedTitle feed={feed} />
<span className="text-material-opaque">·</span>
{!!entry.publishedAt && <RelativeTime date={entry.publishedAt} />}
</div>
</div>
</div>
</div>
</div>
)
}
export const VideoItemSkeleton = (
<div className="relative mx-auto w-full max-w-lg rounded-md">
<div className="relative">
<div className="p-1.5">
<div className="w-full">
<div className="overflow-x-auto">
<Skeleton className="aspect-video w-full shrink-0 overflow-hidden" />
</div>
</div>
<div className="relative flex-1 px-2 pb-3 pt-1 text-sm">
<div className="relative mb-1 mt-1.5 truncate font-medium leading-none">
<Skeleton className="h-4 w-3/4" />
</div>
<div className="mt-1 flex items-center gap-1 truncate text-[13px]">
<Skeleton className="mr-0.5 size-4" />
<Skeleton className="h-3 w-1/2" />
<span className="text-material-opaque">·</span>
<Skeleton className="h-3 w-12" />
</div>
</div>
</div>
</div>
</div>
)

View File

@ -1,304 +1,15 @@
import { isMobile } from "@follow/components/hooks/useMobile.js"
import { Skeleton } from "@follow/components/ui/skeleton/index.jsx"
import { IN_ELECTRON } from "@follow/shared/constants"
import { useEntry } from "@follow/store/entry/hooks"
import { useEntryTranslation, usePrefetchEntryTranslation } from "@follow/store/translation/hooks"
import { stopPropagation } from "@follow/utils/dom"
import { formatDuration } from "@follow/utils/duration"
import { transformVideoUrl } from "@follow/utils/url-for-video"
import { cn } from "@follow/utils/utils"
import { useHover } from "@use-gesture/react"
import { useEffect, useMemo, useRef, useState } from "react"
import { withFeature } from "~/lib/features"
import { AudioPlayer } from "~/atoms/player"
import { useActionLanguage, useGeneralSettingKey } from "~/atoms/settings/general"
import { m } from "~/components/common/Motion"
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 type { ModalContentComponent } from "~/components/ui/modal"
import { FixedModalCloseButton } from "~/components/ui/modal/components/close"
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { checkLanguage } from "~/lib/translate"
import { EntryContent } from "~/modules/entry-content/components/entry-content/EntryContent"
import { FeedIcon } from "~/modules/feed/feed-icon"
import { FeedTitle } from "~/modules/feed/feed-title"
import {
VideoItem as VideoItemAI,
VideoItemStateLess as VideoItemAIStateLess,
} from "./video-item.ai"
import {
VideoItem as VideoItemLegacy,
VideoItemStateLess as VideoItemLegacyStateLess,
} from "./video-item.legacy"
import { GridItem } from "../templates/grid-item-template"
import type { EntryItemStatelessProps, UniversalItemProps } from "../types"
export const VideoItem = withFeature("ai")(VideoItemAI, VideoItemLegacy)
export const VideoItemStateLess = withFeature("ai")(VideoItemAIStateLess, VideoItemLegacyStateLess)
const ViewTag = IN_ELECTRON ? "webview" : "iframe"
export function VideoItem({ entryId, entryPreview, translation }: UniversalItemProps) {
const entry = useEntry(entryId, (state) => {
const { id, url } = state
const attachments = state.attachments || []
const { duration_in_seconds } =
attachments?.find((attachment) => attachment.duration_in_seconds) ?? {}
const seconds = duration_in_seconds
? Number.parseInt(duration_in_seconds.toString())
: undefined
const duration = formatDuration(seconds)
const media = state.media || []
const firstMedia = media[0]
return { attachments, duration, firstMedia, id, url, media }
})
const isActive = useRouteParamsSelector(({ entryId }) => entryId === entry?.id)
const [miniIframeSrc, iframeSrc] = useMemo(
() => [
transformVideoUrl({
url: entry?.url ?? "",
mini: true,
isIframe: !IN_ELECTRON,
attachments: entry?.attachments,
}),
transformVideoUrl({
url: entry?.url ?? "",
isIframe: !IN_ELECTRON,
attachments: entry?.attachments,
}),
],
[entry?.attachments, entry?.url],
)
const modalStack = useModalStack()
const entryContent = useMemo(() => <EntryContent entryId={entryId} noMedia compact />, [entryId])
const previewMedia = usePreviewMedia(entryContent)
const ref = useRef<HTMLDivElement>(null)
const [hovered, setHovered] = useState(false)
useHover(
(event) => {
setHovered(event.active)
},
{
target: ref,
},
)
const [showPreview, setShowPreview] = useState(false)
useEffect(() => {
if (hovered) {
const timer = setTimeout(() => {
setShowPreview(true)
}, 500)
return () => clearTimeout(timer)
} else {
setShowPreview(false)
return () => {}
}
}, [hovered])
if (!entry) return null
return (
<GridItem entryId={entryId} entryPreview={entryPreview} translation={translation}>
<div
className="cursor-card w-full"
onClick={(e) => {
if (isMobile() && entry.url) {
window.open(entry.url, "_blank")
e.stopPropagation()
return
}
if (iframeSrc) {
modalStack.present({
title: "",
content: (props) => (
<PreviewVideoModalContent src={iframeSrc} entryId={entryId} {...props} />
),
clickOutsideToDismiss: true,
CustomModalComponent: PlainModal,
overlay: true,
})
} else {
previewMedia(entry.media)
}
}}
>
<div className="relative overflow-x-auto" ref={ref}>
{miniIframeSrc && showPreview ? (
<ViewTag
src={miniIframeSrc}
className={cn(
"pointer-events-none aspect-video w-full shrink-0 rounded-md bg-black object-cover",
isActive && "rounded-b-none",
)}
/>
) : entry.firstMedia ? (
<Media
key={entry.firstMedia.url}
src={entry.firstMedia.url}
type={entry.firstMedia.type}
previewImageUrl={entry.firstMedia.preview_image_url}
className={cn(
"aspect-video w-full shrink-0 rounded-md object-cover",
isActive && "rounded-b-none",
)}
loading="lazy"
proxy={{
width: 640,
height: 360,
}}
showFallback={true}
/>
) : (
<div className="center bg-material-medium text-text-secondary aspect-video w-full flex-col gap-1 rounded-md text-xs">
<i className="i-mgc-sad-cute-re size-6" />
No media available
</div>
)}
{!!entry.duration && (
<div className="absolute bottom-2 right-2 rounded-md bg-black/50 px-1 py-0.5 text-xs font-medium text-white">
{entry.duration}
</div>
)}
</div>
</div>
</GridItem>
)
}
const PreviewVideoModalContent: ModalContentComponent<{
src: string
entryId: string
}> = ({ dismiss, src, entryId }) => {
const entry = useEntry(entryId, (state) => ({ content: state.content }))
const enableTranslation = useGeneralSettingKey("translation")
const actionLanguage = useActionLanguage()
const translation = useEntryTranslation({
entryId,
language: actionLanguage,
setting: enableTranslation,
})
usePrefetchEntryTranslation({
entryIds: [entryId],
checkLanguage,
setting: enableTranslation,
language: actionLanguage,
withContent: true,
})
const content = translation?.content || entry?.content
const currentAudioPlayerIsPlay = useRef(AudioPlayer.get().status === "playing")
const renderStyle = useRenderStyle()
useEffect(() => {
const currentValue = currentAudioPlayerIsPlay.current
if (currentValue) {
AudioPlayer.pause()
}
return () => {
if (currentValue) {
AudioPlayer.play()
}
}
}, [])
return (
<m.div exit={{ scale: 0.94, opacity: 0 }} className="size-full p-12" onClick={() => dismiss()}>
<m.div
onFocusCapture={stopPropagation}
initial={true}
exit={{
opacity: 0,
}}
className="safe-inset-top-4 fixed right-4 flex items-center"
>
<FixedModalCloseButton onClick={dismiss} />
</m.div>
<ViewTag src={src} className="size-full" />
{!!content && (
<div className="bg-background p-10 pt-5 backdrop-blur-sm">
<HTML
as="div"
className="prose dark:prose-invert !max-w-full"
noMedia
style={renderStyle}
>
{content}
</HTML>
</div>
)}
</m.div>
)
}
export function VideoItemStateLess({ entry, feed }: EntryItemStatelessProps) {
return (
<div className="text-text relative mx-auto w-full max-w-lg rounded-md transition-colors">
<div className="relative">
<div className="p-1.5">
<div className="w-full">
<div className="overflow-x-auto">
{entry.media?.[0] ? (
<Media
thumbnail
src={entry.media[0].url}
type={entry.media[0].type}
previewImageUrl={entry.media[0].preview_image_url}
className="aspect-video w-full shrink-0 overflow-hidden"
mediaContainerClassName={"w-auto h-auto rounded"}
loading="lazy"
proxy={{
width: 0,
height: 0,
}}
height={entry.media[0].height}
width={entry.media[0].width}
blurhash={entry.media[0].blurhash}
/>
) : (
<Skeleton className="aspect-video w-full shrink-0 overflow-hidden" />
)}
</div>
</div>
<div className="relative flex-1 px-2 pb-3 pt-1 text-sm">
<div className="relative mb-1 mt-1.5 truncate font-medium leading-none">
{entry.title}
</div>
<div className="text-text-secondary mt-1 flex items-center gap-1 truncate text-[13px]">
<FeedIcon feed={feed} fallback className="size-4" />
<FeedTitle feed={feed} />
<span className="text-material-opaque">·</span>
{!!entry.publishedAt && <RelativeTime date={entry.publishedAt} />}
</div>
</div>
</div>
</div>
</div>
)
}
export const VideoItemSkeleton = (
<div className="relative mx-auto w-full max-w-lg rounded-md">
<div className="relative">
<div className="p-1.5">
<div className="w-full">
<div className="overflow-x-auto">
<Skeleton className="aspect-video w-full shrink-0 overflow-hidden" />
</div>
</div>
<div className="relative flex-1 px-2 pb-3 pt-1 text-sm">
<div className="relative mb-1 mt-1.5 truncate font-medium leading-none">
<Skeleton className="h-4 w-3/4" />
</div>
<div className="mt-1 flex items-center gap-1 truncate text-[13px]">
<Skeleton className="mr-0.5 size-4" />
<Skeleton className="h-3 w-1/2" />
<span className="text-material-opaque">·</span>
<Skeleton className="h-3 w-12" />
</div>
</div>
</div>
</div>
</div>
)
export { VideoItemSkeleton } from "./video-item.ai"

View File

@ -1,4 +1,6 @@
import { readableContentMaxWidthClassName } from "~/constants/ui"
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`
export const readableContentMaxWidth = tw`${readableContentMaxWidthClassName} mx-auto px-4`

View File

@ -0,0 +1,243 @@
import { Spring } from "@follow/components/constants/spring.js"
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
import { RootPortal } from "@follow/components/ui/portal/index.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import { FeedViewType } from "@follow/constants"
import { useTitle } from "@follow/hooks"
import type { FeedModel } from "@follow/models/types"
import { useEntry } from "@follow/store/entry/hooks"
import { useFeedById } from "@follow/store/feed/hooks"
import { useIsInbox } from "@follow/store/inbox/hooks"
import { nextFrame, stopPropagation } from "@follow/utils/dom"
import { EventBus } from "@follow/utils/event-bus"
import { clsx, cn } from "@follow/utils/utils"
import type { JSAnimation, Variants } from "motion/react"
import { m, useAnimationControls } from "motion/react"
import * as React from "react"
import { memo, useEffect, useRef, useState } from "react"
import { useEntryIsInReadability } from "~/atoms/readability"
import { useIsZenMode } from "~/atoms/settings/ui"
import { Focusable } from "~/components/common/Focusable"
import { useInPeekModal } from "~/components/ui/modal/inspire/InPeekModal"
import { HotkeyScope } from "~/constants"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useFeedSafeUrl } from "~/hooks/common/useFeedSafeUrl"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { ApplyEntryActions } from "../../ApplyEntryActions"
import { useEntryContent } from "../../hooks"
import { EntryHeader } from "../entry-header"
import { EntryTimelineSidebar } from "../EntryTimelineSidebar"
import { getEntryContentLayout } from "../layouts"
import { SourceContentPanel } from "../SourceContentView"
import { AISmartSidebar } from "./ai"
import { EntryCommandShortcutRegister } from "./EntryCommandShortcutRegister"
import { EntryContentLoading } from "./EntryContentLoading"
import { EntryNoContent } from "./EntryNoContent"
import { EntryScrollingAndNavigationHandler } from "./EntryScrollingAndNavigationHandler.js"
import type { EntryContentProps } from "./types"
const pageMotionVariants = {
initial: { opacity: 0, y: 25 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 25, transition: { duration: 0 } },
} satisfies Variants
const EntryContentImpl: Component<EntryContentProps> = ({
entryId,
noMedia,
className,
compact,
classNames,
}) => {
const entry = useEntry(entryId, (state) => {
const { feedId, inboxHandle } = state
const { title, url } = state
return { feedId, inboxId: inboxHandle, title, url }
})
useTitle(entry?.title)
const feed = useFeedById(entry?.feedId)
const isInbox = useIsInbox(entry?.inboxId)
const isInReadabilityMode = useEntryIsInReadability(entryId)
const { error, content, isPending } = useEntryContent(entryId)
const view = useRouteParamsSelector((route) => route.view)
const scrollerRef = useRef<HTMLDivElement | null>(null)
const safeUrl = useFeedSafeUrl(entryId)
const isInPeekModal = useInPeekModal()
const isZenMode = useIsZenMode()
const [panelPortalElement, setPanelPortalElement] = useState<HTMLDivElement | null>(null)
const animationController = useAnimationControls()
const prevEntryId = useRef<string | undefined>(undefined)
const scrollAnimationRef = useRef<JSAnimation<any> | null>(null)
useEffect(() => {
if (prevEntryId.current !== entryId) {
scrollAnimationRef.current?.stop()
nextFrame(() => {
scrollerRef.current?.scrollTo({ top: 0 })
})
animationController.start(pageMotionVariants.exit).then(() => {
animationController.start(pageMotionVariants.animate)
})
prevEntryId.current = entryId
}
}, [animationController, entryId])
const isInHasTimelineView = ![
FeedViewType.Pictures,
FeedViewType.SocialMedia,
FeedViewType.Videos,
].includes(view)
if (!entry) return null
return (
<div className={cn(className, "flex flex-col")}>
<EntryCommandShortcutRegister entryId={entryId} view={view} />
{!isInPeekModal && (
<EntryHeader
entryId={entryId}
view={view}
className={cn("@container h-[55px] shrink-0 px-3", classNames?.header)}
compact={compact}
/>
)}
<div className="w-full" ref={setPanelPortalElement} />
<Focusable
scope={HotkeyScope.EntryRender}
className="@container relative flex min-h-0 w-full flex-1 flex-col overflow-hidden print:size-auto print:overflow-visible"
>
<RootPortal to={panelPortalElement}>
<EntryScrollingAndNavigationHandler
scrollAnimationRef={scrollAnimationRef}
scrollerRef={scrollerRef}
/>
</RootPortal>
<EntryTimelineSidebar entryId={entryId} />
<EntryScrollArea scrollerRef={scrollerRef}>
{/* Indicator for the entry */}
<m.div
initial={pageMotionVariants.initial}
animate={animationController}
transition={Spring.presets.bouncy}
className="select-text"
>
{!isZenMode && isInHasTimelineView && !isInPeekModal && (
<>
<div className="absolute inset-y-0 left-0 flex w-12 items-center justify-center opacity-0 duration-200 hover:opacity-100">
<MotionButtonBase
// -12 Visual center point
className="absolute left-0 shrink-0 !-translate-y-12 cursor-pointer"
onClick={() => {
EventBus.dispatch(COMMAND_ID.timeline.switchToPrevious)
}}
>
<i className="i-mgc-left-small-sharp text-text-secondary size-16" />
</MotionButtonBase>
</div>
<div className="absolute inset-y-0 right-0 flex w-12 items-center justify-center opacity-0 duration-200 hover:opacity-100">
<MotionButtonBase
className="absolute right-0 shrink-0 !-translate-y-12 cursor-pointer"
onClick={() => {
EventBus.dispatch(COMMAND_ID.timeline.switchToNext)
}}
>
<i className="i-mgc-right-small-sharp text-text-secondary size-16" />
</MotionButtonBase>
</div>
</>
)}
<article
data-testid="entry-render"
onContextMenu={stopPropagation}
className={clsx(
"relative w-full min-w-0 pb-10 pt-2",
isInPeekModal ? "max-w-full" : view === FeedViewType.Articles ? "" : "max-w-full",
)}
>
<ApplyEntryActions entryId={entryId} key={entryId} />
{!content && !isInReadabilityMode ? (
<div className="center mt-16 min-w-0">
{isPending ? (
<EntryContentLoading
icon={!isInbox ? (feed as FeedModel)?.siteUrl : undefined}
/>
) : error ? (
<div className="center mt-36 flex flex-col items-center gap-3">
<i className="i-mgc-warning-cute-re text-red text-4xl" />
<span className="text-balance text-center text-sm">Network Error</span>
<pre className="mt-6 w-full overflow-auto whitespace-pre-wrap break-all">
{error.message}
</pre>
</div>
) : (
<EntryNoContent id={entryId} url={entry.url ?? ""} />
)}
</div>
) : (
<AdaptiveContentRenderer
entryId={entryId}
view={view}
compact={compact}
noMedia={noMedia}
/>
)}
</article>
</m.div>
</EntryScrollArea>
<SourceContentPanel src={safeUrl ?? "#"} />
</Focusable>
<React.Suspense>{!isInPeekModal && <AISmartSidebar entryId={entryId} />}</React.Suspense>
</div>
)
}
export const EntryContent = memo(EntryContentImpl)
const EntryScrollArea: Component<{
scrollerRef: React.RefObject<HTMLDivElement | null>
}> = ({ children, className, scrollerRef }) => {
const isInPeekModal = useInPeekModal()
if (isInPeekModal) {
return <div className="p-5">{children}</div>
}
return (
<ScrollArea.ScrollArea
focusable
mask={false}
stopWheelPropagation={false}
flex
rootClassName={cn(
"flex-1 min-h-0 overflow-y-auto print:h-auto print:overflow-visible",
className,
)}
scrollbarClassName="mr-[1.5px] print:hidden"
ref={scrollerRef}
>
{children}
</ScrollArea.ScrollArea>
)
}
const AdaptiveContentRenderer: React.FC<{
entryId: string
view: FeedViewType
compact?: boolean
noMedia?: boolean
}> = ({ entryId, view, compact = false, noMedia = false }) => {
const LayoutComponent = getEntryContentLayout(view)
return <LayoutComponent entryId={entryId} compact={compact} noMedia={noMedia} />
}

View File

@ -0,0 +1,313 @@
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js"
import { Spring } from "@follow/components/constants/spring.js"
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
import { RootPortal } from "@follow/components/ui/portal/index.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import { FeedViewType } from "@follow/constants"
import { useTitle } from "@follow/hooks"
import type { FeedModel } from "@follow/models/types"
import { useEntry } from "@follow/store/entry/hooks"
import { useFeedById } from "@follow/store/feed/hooks"
import { useIsInbox } from "@follow/store/inbox/hooks"
import { nextFrame, stopPropagation } from "@follow/utils/dom"
import { EventBus } from "@follow/utils/event-bus"
import { clsx, cn } from "@follow/utils/utils"
import { ErrorBoundary } from "@sentry/react"
import type { JSAnimation, Variants } from "motion/react"
import { m, useAnimationControls } from "motion/react"
import * as React from "react"
import { memo, useEffect, useMemo, useRef, useState } from "react"
import { useEntryIsInReadability } from "~/atoms/readability"
import { useIsZenMode, useUISettingKey } from "~/atoms/settings/ui"
import { Focusable } from "~/components/common/Focusable"
import { ShadowDOM } from "~/components/common/ShadowDOM"
import type { TocRef } from "~/components/ui/markdown/components/Toc"
import { useInPeekModal } from "~/components/ui/modal/inspire/InPeekModal"
import { HotkeyScope } from "~/constants"
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useFeedSafeUrl } from "~/hooks/common/useFeedSafeUrl"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { EntryContentHTMLRenderer } from "~/modules/renderer/html"
import { WrappedElementProvider } from "~/providers/wrapped-element-provider"
import { AISummary } from "../../AISummary"
import { ApplyEntryActions } from "../../ApplyEntryActions"
import { useEntryContent, useEntryMediaInfo } from "../../hooks"
import { EntryHeader } from "../entry-header"
import { EntryAttachments } from "../EntryAttachments"
import { EntryTimelineSidebar } from "../EntryTimelineSidebar"
import { EntryTitle } from "../EntryTitle"
import { SourceContentPanel } from "../SourceContentView"
import { SupportCreator } from "../SupportCreator"
import { EntryContentAccessories } from "./accessories"
import { AISmartSidebar } from "./ai"
import { EntryCommandShortcutRegister } from "./EntryCommandShortcutRegister"
import { EntryContentLoading } from "./EntryContentLoading"
import { EntryNoContent } from "./EntryNoContent"
import { EntryRenderError } from "./EntryRenderError"
import { EntryScrollingAndNavigationHandler } from "./EntryScrollingAndNavigationHandler.js"
import { EntryTitleMetaHandler } from "./EntryTitleMetaHandler"
import { ReadabilityNotice } from "./ReadabilityNotice"
import type { EntryContentProps } from "./types"
const pageMotionVariants = {
initial: { opacity: 0, y: 25 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 25, transition: { duration: 0 } },
} satisfies Variants
const EntryContentImpl: Component<EntryContentProps> = ({
entryId,
noMedia,
className,
compact,
classNames,
}) => {
const entry = useEntry(entryId, (state) => {
const { feedId, inboxHandle } = state
const { title, url } = state
return { feedId, inboxId: inboxHandle, title, url }
})
useTitle(entry?.title)
const feed = useFeedById(entry?.feedId)
const isInbox = useIsInbox(entry?.inboxId)
const isInReadabilityMode = useEntryIsInReadability(entryId)
const { error, content, isPending } = useEntryContent(entryId)
const view = useRouteParamsSelector((route) => route.view)
const scrollerRef = useRef<HTMLDivElement | null>(null)
const safeUrl = useFeedSafeUrl(entryId)
const customCSS = useUISettingKey("customCSS")
const isInPeekModal = useInPeekModal()
const isZenMode = useIsZenMode()
const [panelPortalElement, setPanelPortalElement] = useState<HTMLDivElement | null>(null)
const animationController = useAnimationControls()
const prevEntryId = useRef<string | undefined>(undefined)
const scrollAnimationRef = useRef<JSAnimation<any> | null>(null)
useEffect(() => {
if (prevEntryId.current !== entryId) {
scrollAnimationRef.current?.stop()
nextFrame(() => {
scrollerRef.current?.scrollTo({ top: 0 })
})
animationController.start(pageMotionVariants.exit).then(() => {
animationController.start(pageMotionVariants.animate)
})
prevEntryId.current = entryId
}
}, [animationController, entryId])
const isInHasTimelineView = ![
FeedViewType.Pictures,
FeedViewType.SocialMedia,
FeedViewType.Videos,
].includes(view)
if (!entry) return null
return (
<>
<EntryCommandShortcutRegister entryId={entryId} view={view} />
{!isInPeekModal && (
<EntryHeader
entryId={entryId}
view={view}
className={cn("@container h-[55px] shrink-0 px-3", classNames?.header)}
compact={compact}
/>
)}
<div className="w-full" ref={setPanelPortalElement} />
<Focusable
scope={HotkeyScope.EntryRender}
className="@container relative flex size-full flex-col overflow-hidden print:size-auto print:overflow-visible"
>
<RootPortal to={panelPortalElement}>
<EntryScrollingAndNavigationHandler
scrollAnimationRef={scrollAnimationRef}
scrollerRef={scrollerRef}
/>
</RootPortal>
<EntryTimelineSidebar entryId={entryId} />
<EntryScrollArea className={className} scrollerRef={scrollerRef}>
{/* Indicator for the entry */}
<m.div
initial={pageMotionVariants.initial}
animate={animationController}
transition={Spring.presets.bouncy}
className="select-text"
>
{!isZenMode && isInHasTimelineView && !isInPeekModal && (
<>
<div className="absolute inset-y-0 left-0 flex w-12 items-center justify-center opacity-0 duration-200 hover:opacity-100">
<MotionButtonBase
// -12 Visual center point
className="absolute left-0 shrink-0 !-translate-y-12 cursor-pointer"
onClick={() => {
EventBus.dispatch(COMMAND_ID.timeline.switchToPrevious)
}}
>
<i className="i-mgc-left-small-sharp text-text-secondary size-16" />
</MotionButtonBase>
</div>
<div className="absolute inset-y-0 right-0 flex w-12 items-center justify-center opacity-0 duration-200 hover:opacity-100">
<MotionButtonBase
className="absolute right-0 shrink-0 !-translate-y-12 cursor-pointer"
onClick={() => {
EventBus.dispatch(COMMAND_ID.timeline.switchToNext)
}}
>
<i className="i-mgc-right-small-sharp text-text-secondary size-16" />
</MotionButtonBase>
</div>
</>
)}
<article
data-testid="entry-render"
onContextMenu={stopPropagation}
className={clsx(
"relative m-auto min-w-0",
isInPeekModal
? "max-w-full"
: "@[950px]:max-w-[70ch] @7xl:max-w-[80ch] max-w-[550px]",
)}
>
<EntryTitle entryId={entryId} compact={compact} />
<WrappedElementProvider boundingDetection>
<div className="mx-auto mb-32 mt-8 max-w-full cursor-auto text-[0.94rem]">
<EntryTitleMetaHandler entryId={entryId} />
<AISummary entryId={entryId} />
<ErrorBoundary fallback={EntryRenderError}>
<ReadabilityNotice entryId={entryId} />
<ShadowDOM injectHostStyles={!isInbox}>
{!!customCSS && (
<MemoedDangerousHTMLStyle>{customCSS}</MemoedDangerousHTMLStyle>
)}
<Renderer
entryId={entryId}
view={view}
feedId={feed?.id || ""}
noMedia={noMedia}
content={content}
/>
</ShadowDOM>
</ErrorBoundary>
</div>
</WrappedElementProvider>
<ApplyEntryActions entryId={entryId} key={entryId} />
{!content && !isInReadabilityMode && (
<div className="center mt-16 min-w-0">
{isPending ? (
<EntryContentLoading
icon={!isInbox ? (feed as FeedModel)?.siteUrl : undefined}
/>
) : error ? (
<div className="center mt-36 flex flex-col items-center gap-3">
<i className="i-mgc-warning-cute-re text-red text-4xl" />
<span className="text-balance text-center text-sm">Network Error</span>
<pre className="mt-6 w-full overflow-auto whitespace-pre-wrap break-all">
{error.message}
</pre>
</div>
) : (
<EntryNoContent id={entryId} url={entry.url ?? ""} />
)}
</div>
)}
<EntryAttachments entryId={entryId} />
<SupportCreator entryId={entryId} />
</article>
</m.div>
</EntryScrollArea>
<SourceContentPanel src={safeUrl ?? "#"} />
</Focusable>
<React.Suspense>{!isInPeekModal && <AISmartSidebar entryId={entryId} />}</React.Suspense>
</>
)
}
export const EntryContent = memo(EntryContentImpl)
const EntryScrollArea: Component<{
scrollerRef: React.RefObject<HTMLDivElement | null>
}> = ({ children, className, scrollerRef }) => {
const isInPeekModal = useInPeekModal()
if (isInPeekModal) {
return <div className="p-5">{children}</div>
}
return (
<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,
)}
scrollbarClassName="mr-[1.5px] print:hidden"
viewportClassName="p-5"
ref={scrollerRef}
>
{children}
</ScrollArea.ScrollArea>
)
}
const Renderer: React.FC<{
entryId: string
view: FeedViewType
feedId: string
noMedia?: boolean
content?: Nullable<string>
}> = React.memo(({ entryId, view, feedId, noMedia = false, content = "" }) => {
const mediaInfo = useEntryMediaInfo(entryId)
const readerRenderInlineStyle = useUISettingKey("readerRenderInlineStyle")
const stableRenderStyle = useRenderStyle()
const isInPeekModal = useInPeekModal()
const tocRef = useRef<TocRef | null>(null)
const contentAccessories = useMemo(
() => (isInPeekModal ? undefined : <EntryContentAccessories ref={{ tocRef }} />),
[isInPeekModal],
)
useEffect(() => {
if (tocRef) {
tocRef.current?.refreshItems()
}
}, [content, tocRef])
return (
<EntryContentHTMLRenderer
view={view}
feedId={feedId}
entryId={entryId}
mediaInfo={mediaInfo}
noMedia={noMedia}
accessory={contentAccessories}
as="article"
className="prose dark:prose-invert prose-h1:text-[1.6em] prose-h1:font-bold !max-w-full hyphens-auto"
style={stableRenderStyle}
renderInlineStyle={readerRenderInlineStyle}
>
{content}
</EntryContentHTMLRenderer>
)
})

View File

@ -1,313 +1,6 @@
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js"
import { Spring } from "@follow/components/constants/spring.js"
import { MotionButtonBase } from "@follow/components/ui/button/index.js"
import { RootPortal } from "@follow/components/ui/portal/index.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import { FeedViewType } from "@follow/constants"
import { useTitle } from "@follow/hooks"
import type { FeedModel } from "@follow/models/types"
import { useEntry } from "@follow/store/entry/hooks"
import { useFeedById } from "@follow/store/feed/hooks"
import { useIsInbox } from "@follow/store/inbox/hooks"
import { nextFrame, stopPropagation } from "@follow/utils/dom"
import { EventBus } from "@follow/utils/event-bus"
import { clsx, cn } from "@follow/utils/utils"
import { ErrorBoundary } from "@sentry/react"
import type { JSAnimation, Variants } from "motion/react"
import { m, useAnimationControls } from "motion/react"
import * as React from "react"
import { memo, useEffect, useMemo, useRef, useState } from "react"
import { withFeature } from "~/lib/features"
import { useEntryIsInReadability } from "~/atoms/readability"
import { useIsZenMode, useUISettingKey } from "~/atoms/settings/ui"
import { Focusable } from "~/components/common/Focusable"
import { ShadowDOM } from "~/components/common/ShadowDOM"
import type { TocRef } from "~/components/ui/markdown/components/Toc"
import { useInPeekModal } from "~/components/ui/modal/inspire/InPeekModal"
import { HotkeyScope } from "~/constants"
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useFeedSafeUrl } from "~/hooks/common/useFeedSafeUrl"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { EntryContentHTMLRenderer } from "~/modules/renderer/html"
import { WrappedElementProvider } from "~/providers/wrapped-element-provider"
import { EntryContent as EntryContentAI } from "./EntryContent.ai"
import { EntryContent as EntryContentLegacy } from "./EntryContent.legacy"
import { AISummary } from "../../AISummary"
import { ApplyEntryActions } from "../../ApplyEntryActions"
import { useEntryContent, useEntryMediaInfo } from "../../hooks"
import { EntryHeader } from "../entry-header"
import { EntryAttachments } from "../EntryAttachments"
import { EntryTimelineSidebar } from "../EntryTimelineSidebar"
import { EntryTitle } from "../EntryTitle"
import { SourceContentPanel } from "../SourceContentView"
import { SupportCreator } from "../SupportCreator"
import { EntryContentAccessories } from "./accessories"
import { AISmartSidebar } from "./ai"
import { EntryCommandShortcutRegister } from "./EntryCommandShortcutRegister"
import { EntryContentLoading } from "./EntryContentLoading"
import { EntryNoContent } from "./EntryNoContent"
import { EntryRenderError } from "./EntryRenderError"
import { EntryScrollingAndNavigationHandler } from "./EntryScrollingAndNavigationHandler.js"
import { EntryTitleMetaHandler } from "./EntryTitleMetaHandler"
import { ReadabilityNotice } from "./ReadabilityNotice"
import type { EntryContentProps } from "./types"
const pageMotionVariants = {
initial: { opacity: 0, y: 25 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: 25, transition: { duration: 0 } },
} satisfies Variants
const EntryContentImpl: Component<EntryContentProps> = ({
entryId,
noMedia,
className,
compact,
classNames,
}) => {
const entry = useEntry(entryId, (state) => {
const { feedId, inboxHandle } = state
const { title, url } = state
return { feedId, inboxId: inboxHandle, title, url }
})
useTitle(entry?.title)
const feed = useFeedById(entry?.feedId)
const isInbox = useIsInbox(entry?.inboxId)
const isInReadabilityMode = useEntryIsInReadability(entryId)
const { error, content, isPending } = useEntryContent(entryId)
const view = useRouteParamsSelector((route) => route.view)
const scrollerRef = useRef<HTMLDivElement | null>(null)
const safeUrl = useFeedSafeUrl(entryId)
const customCSS = useUISettingKey("customCSS")
const isInPeekModal = useInPeekModal()
const isZenMode = useIsZenMode()
const [panelPortalElement, setPanelPortalElement] = useState<HTMLDivElement | null>(null)
const animationController = useAnimationControls()
const prevEntryId = useRef<string | undefined>(undefined)
const scrollAnimationRef = useRef<JSAnimation<any> | null>(null)
useEffect(() => {
if (prevEntryId.current !== entryId) {
scrollAnimationRef.current?.stop()
nextFrame(() => {
scrollerRef.current?.scrollTo({ top: 0 })
})
animationController.start(pageMotionVariants.exit).then(() => {
animationController.start(pageMotionVariants.animate)
})
prevEntryId.current = entryId
}
}, [animationController, entryId])
const isInHasTimelineView = ![
FeedViewType.Pictures,
FeedViewType.SocialMedia,
FeedViewType.Videos,
].includes(view)
if (!entry) return null
return (
<>
<EntryCommandShortcutRegister entryId={entryId} view={view} />
{!isInPeekModal && (
<EntryHeader
entryId={entryId}
view={view}
className={cn("@container h-[55px] shrink-0 px-3", classNames?.header)}
compact={compact}
/>
)}
<div className="w-full" ref={setPanelPortalElement} />
<Focusable
scope={HotkeyScope.EntryRender}
className="@container relative flex size-full flex-col overflow-hidden print:size-auto print:overflow-visible"
>
<RootPortal to={panelPortalElement}>
<EntryScrollingAndNavigationHandler
scrollAnimationRef={scrollAnimationRef}
scrollerRef={scrollerRef}
/>
</RootPortal>
<EntryTimelineSidebar entryId={entryId} />
<EntryScrollArea className={className} scrollerRef={scrollerRef}>
{/* Indicator for the entry */}
<m.div
initial={pageMotionVariants.initial}
animate={animationController}
transition={Spring.presets.bouncy}
className="select-text"
>
{!isZenMode && isInHasTimelineView && !isInPeekModal && (
<>
<div className="absolute inset-y-0 left-0 flex w-12 items-center justify-center opacity-0 duration-200 hover:opacity-100">
<MotionButtonBase
// -12 Visual center point
className="absolute left-0 shrink-0 !-translate-y-12 cursor-pointer"
onClick={() => {
EventBus.dispatch(COMMAND_ID.timeline.switchToPrevious)
}}
>
<i className="i-mgc-left-small-sharp text-text-secondary size-16" />
</MotionButtonBase>
</div>
<div className="absolute inset-y-0 right-0 flex w-12 items-center justify-center opacity-0 duration-200 hover:opacity-100">
<MotionButtonBase
className="absolute right-0 shrink-0 !-translate-y-12 cursor-pointer"
onClick={() => {
EventBus.dispatch(COMMAND_ID.timeline.switchToNext)
}}
>
<i className="i-mgc-right-small-sharp text-text-secondary size-16" />
</MotionButtonBase>
</div>
</>
)}
<article
data-testid="entry-render"
onContextMenu={stopPropagation}
className={clsx(
"relative m-auto min-w-0",
isInPeekModal
? "max-w-full"
: "@[950px]:max-w-[70ch] @7xl:max-w-[80ch] max-w-[550px]",
)}
>
<EntryTitle entryId={entryId} compact={compact} />
<WrappedElementProvider boundingDetection>
<div className="mx-auto mb-32 mt-8 max-w-full cursor-auto text-[0.94rem]">
<EntryTitleMetaHandler entryId={entryId} />
<AISummary entryId={entryId} />
<ErrorBoundary fallback={EntryRenderError}>
<ReadabilityNotice entryId={entryId} />
<ShadowDOM injectHostStyles={!isInbox}>
{!!customCSS && (
<MemoedDangerousHTMLStyle>{customCSS}</MemoedDangerousHTMLStyle>
)}
<Renderer
entryId={entryId}
view={view}
feedId={feed?.id || ""}
noMedia={noMedia}
content={content}
/>
</ShadowDOM>
</ErrorBoundary>
</div>
</WrappedElementProvider>
<ApplyEntryActions entryId={entryId} key={entryId} />
{!content && !isInReadabilityMode && (
<div className="center mt-16 min-w-0">
{isPending ? (
<EntryContentLoading
icon={!isInbox ? (feed as FeedModel)?.siteUrl : undefined}
/>
) : error ? (
<div className="center mt-36 flex flex-col items-center gap-3">
<i className="i-mgc-warning-cute-re text-red text-4xl" />
<span className="text-balance text-center text-sm">Network Error</span>
<pre className="mt-6 w-full overflow-auto whitespace-pre-wrap break-all">
{error.message}
</pre>
</div>
) : (
<EntryNoContent id={entryId} url={entry.url ?? ""} />
)}
</div>
)}
<EntryAttachments entryId={entryId} />
<SupportCreator entryId={entryId} />
</article>
</m.div>
</EntryScrollArea>
<SourceContentPanel src={safeUrl ?? "#"} />
</Focusable>
<React.Suspense>{!isInPeekModal && <AISmartSidebar entryId={entryId} />}</React.Suspense>
</>
)
}
export const EntryContent = memo(EntryContentImpl)
const EntryScrollArea: Component<{
scrollerRef: React.RefObject<HTMLDivElement | null>
}> = ({ children, className, scrollerRef }) => {
const isInPeekModal = useInPeekModal()
if (isInPeekModal) {
return <div className="p-5">{children}</div>
}
return (
<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,
)}
scrollbarClassName="mr-[1.5px] print:hidden"
viewportClassName="p-5"
ref={scrollerRef}
>
{children}
</ScrollArea.ScrollArea>
)
}
const Renderer: React.FC<{
entryId: string
view: FeedViewType
feedId: string
noMedia?: boolean
content?: Nullable<string>
}> = React.memo(({ entryId, view, feedId, noMedia = false, content = "" }) => {
const mediaInfo = useEntryMediaInfo(entryId)
const readerRenderInlineStyle = useUISettingKey("readerRenderInlineStyle")
const stableRenderStyle = useRenderStyle()
const isInPeekModal = useInPeekModal()
const tocRef = useRef<TocRef | null>(null)
const contentAccessories = useMemo(
() => (isInPeekModal ? undefined : <EntryContentAccessories ref={{ tocRef }} />),
[isInPeekModal],
)
useEffect(() => {
if (tocRef) {
tocRef.current?.refreshItems()
}
}, [content, tocRef])
return (
<EntryContentHTMLRenderer
view={view}
feedId={feedId}
entryId={entryId}
mediaInfo={mediaInfo}
noMedia={noMedia}
accessory={contentAccessories}
as="article"
className="prose dark:prose-invert prose-h1:text-[1.6em] prose-h1:font-bold !max-w-full hyphens-auto"
style={stableRenderStyle}
renderInlineStyle={readerRenderInlineStyle}
>
{content}
</EntryContentHTMLRenderer>
)
})
export const EntryContent = withFeature("ai")(EntryContentAI, EntryContentLegacy)

View File

@ -0,0 +1,129 @@
import { MemoedDangerousHTMLStyle } from "@follow/components/common/MemoedDangerousHTMLStyle.js"
import { FeedViewType } from "@follow/constants"
import { useEntry } from "@follow/store/entry/hooks"
import { useFeedById } from "@follow/store/feed/hooks"
import { useIsInbox } from "@follow/store/inbox/hooks"
import { cn } from "@follow/utils"
import { ErrorBoundary } from "@sentry/react"
import { useMemo, useRef } from "react"
import { useEntryIsInReadability } from "~/atoms/readability"
import { useUISettingKey } from "~/atoms/settings/ui"
import { ShadowDOM } from "~/components/common/ShadowDOM"
import type { TocRef } from "~/components/ui/markdown/components/Toc"
import { useInPeekModal } from "~/components/ui/modal/inspire/InPeekModal"
import { readableContentMaxWidthClassName } from "~/constants/ui"
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
import { EntryContentHTMLRenderer } from "~/modules/renderer/html"
import { WrappedElementProvider } from "~/providers/wrapped-element-provider"
import { AISummary } from "../../AISummary"
import { useEntryContent, useEntryMediaInfo } from "../../hooks"
import { EntryContentAccessories } from "../entry-content/accessories"
import { EntryRenderError } from "../entry-content/EntryRenderError"
import { EntryTitleMetaHandler } from "../entry-content/EntryTitleMetaHandler"
import { ReadabilityNotice } from "../entry-content/ReadabilityNotice"
import { EntryAttachments } from "../EntryAttachments"
import { EntryTitle } from "../EntryTitle"
import { SupportCreator } from "../SupportCreator"
interface ArticleLayoutProps {
entryId: string
compact?: boolean
noMedia?: boolean
translation?: {
content?: string
title?: string
}
}
export const ArticleLayout: React.FC<ArticleLayoutProps> = ({
entryId,
compact = false,
noMedia = false,
translation,
}) => {
const entry = useEntry(entryId, (state) => ({
feedId: state.feedId,
inboxId: state.inboxHandle,
}))
const feed = useFeedById(entry?.feedId)
const isInbox = useIsInbox(entry?.inboxId)
const _isInReadabilityMode = useEntryIsInReadability(entryId)
const { content } = useEntryContent(entryId)
const customCSS = useUISettingKey("customCSS")
const _isInPeekModal = useInPeekModal()
if (!entry) return null
return (
<div className={cn(readableContentMaxWidthClassName, "mx-auto")}>
<EntryTitle entryId={entryId} compact={compact} />
<WrappedElementProvider boundingDetection>
<div className="mx-auto mb-32 mt-8 max-w-full cursor-auto text-[0.94rem]">
<EntryTitleMetaHandler entryId={entryId} />
<AISummary entryId={entryId} />
<ErrorBoundary fallback={EntryRenderError}>
<ReadabilityNotice entryId={entryId} />
<ShadowDOM injectHostStyles={!isInbox}>
{!!customCSS && <MemoedDangerousHTMLStyle>{customCSS}</MemoedDangerousHTMLStyle>}
<Renderer
entryId={entryId}
view={FeedViewType.Articles}
feedId={feed?.id || ""}
noMedia={noMedia}
content={content}
translation={translation}
/>
</ShadowDOM>
</ErrorBoundary>
</div>
</WrappedElementProvider>
<EntryAttachments entryId={entryId} />
<SupportCreator entryId={entryId} />
</div>
)
}
const Renderer: React.FC<{
entryId: string
view: FeedViewType
feedId: string
noMedia?: boolean
content?: Nullable<string>
translation?: {
content?: string
title?: string
}
}> = ({ entryId, view, feedId, noMedia = false, content = "", translation }) => {
const mediaInfo = useEntryMediaInfo(entryId)
const readerRenderInlineStyle = useUISettingKey("readerRenderInlineStyle")
const stableRenderStyle = useRenderStyle()
const isInPeekModal = useInPeekModal()
const tocRef = useRef<TocRef | null>(null)
const contentAccessories = useMemo(
() => (isInPeekModal ? undefined : <EntryContentAccessories ref={{ tocRef }} />),
[isInPeekModal],
)
return (
<EntryContentHTMLRenderer
view={view}
feedId={feedId}
entryId={entryId}
mediaInfo={mediaInfo}
noMedia={noMedia}
accessory={contentAccessories}
as="article"
className="prose dark:prose-invert prose-h1:text-[1.6em] prose-h1:font-bold !max-w-full hyphens-auto"
style={stableRenderStyle}
renderInlineStyle={readerRenderInlineStyle}
>
{translation?.content || content}
</EntryContentHTMLRenderer>
)
}

View File

@ -0,0 +1,56 @@
import { useEntry } from "@follow/store/entry/hooks"
import { cn } from "@follow/utils/utils"
import { usePreviewMedia } from "~/components/ui/media/hooks"
import { SwipeMedia } from "~/components/ui/media/SwipeMedia"
import { readableContentMaxWidthClassName } from "~/constants/ui"
import { AuthorHeader, ContentBody } from "./shared"
interface PicturesLayoutProps {
entryId: string
compact?: boolean
noMedia?: boolean
translation?: {
content?: string
title?: string
}
}
export const PicturesLayout: React.FC<PicturesLayoutProps> = ({
entryId,
compact = false,
noMedia = false,
translation,
}) => {
const entry = useEntry(entryId, (state) => ({ media: state.media, id: state.id }))
const previewMedia = usePreviewMedia()
if (!entry) return null
return (
<div className="group mx-auto max-w-4xl space-y-6 p-6">
{!noMedia && (
<SwipeMedia
media={entry?.media || []}
className={cn("aspect-square", "w-full shrink-0 rounded-md [&_img]:rounded-md")}
imgClassName="object-contain"
onPreview={previewMedia}
proxySize={null}
/>
)}
{/* Single Author header without avatar */}
<AuthorHeader entryId={entryId} className={cn("mx-auto", readableContentMaxWidthClassName)} />
{/* Text Content Section */}
<ContentBody
entryId={entryId}
translation={translation}
compact={compact}
noMedia={true}
className="mx-auto"
/>
</div>
)
}

View File

@ -0,0 +1,50 @@
import { useEntry } from "@follow/store/entry/hooks"
import { useFeedById } from "@follow/store/feed/hooks"
import { cn } from "@follow/utils/utils"
import { readableContentMaxWidthClassName } from "~/constants/ui"
import { SocialMediaGallery } from "~/modules/entry-column/Items/social-media-item"
import { AuthorHeader } from "./shared/AuthorHeader"
import { ContentBody } from "./shared/ContentBody"
interface SocialMediaLayoutProps {
entryId: string
compact?: boolean
noMedia?: boolean
translation?: {
content?: string
title?: string
}
}
export const SocialMediaLayout: React.FC<SocialMediaLayoutProps> = ({
entryId,
compact = false,
noMedia = false,
translation,
}) => {
const entry = useEntry(entryId, (state) => ({ feedId: state.feedId, media: state.media }))
const feed = useFeedById(entry?.feedId)
if (!entry || !feed) return null
return (
<div className={cn(readableContentMaxWidthClassName, "mx-auto space-y-5")}>
{/* Single Author header without avatar */}
<AuthorHeader entryId={entryId} />
{/* Main content - direct ContentBody usage without show more logic */}
<ContentBody
entryId={entryId}
translation={translation}
compact={compact}
className="text-base leading-relaxed"
noMedia={true}
/>
{/* Media gallery */}
{!noMedia && <SocialMediaGallery entryId={entryId} />}
</div>
)
}

View File

@ -0,0 +1,63 @@
import { useEntry } from "@follow/store/entry/hooks"
import { EntryTitle } from "../EntryTitle"
import { ContentBody } from "./shared/ContentBody"
import { VideoPlayer } from "./shared/VideoPlayer"
interface VideosLayoutProps {
entryId: string
compact?: boolean
noMedia?: boolean
translation?: {
content?: string
title?: string
}
}
export const VideosLayout: React.FC<VideosLayoutProps> = ({
entryId,
compact = false,
noMedia = false,
translation,
}) => {
const entry = useEntry(entryId, (state) => state)
if (!entry) return null
return (
<div className="mx-auto flex h-full flex-col p-6">
{/* Video player area */}
<div className="mb-6 w-full">
{!noMedia ? (
<VideoPlayer
entryId={entryId}
showDuration={true}
preferFullSize={true}
translation={translation}
className="w-full"
/>
) : (
<div className="center bg-material-medium text-text-secondary aspect-video w-full flex-col gap-1 rounded-md text-sm">
<i className="i-mgc-video-cute-fi mb-2 size-12" />
Video content not available
</div>
)}
</div>
{/* Content area below video */}
<div className="flex-1 space-y-4">
{/* Title */}
<EntryTitle entryId={entryId} compact={compact} />
{/* Description/Content */}
<ContentBody
entryId={entryId}
translation={translation}
compact={compact}
noMedia={true}
className="text-base"
/>
</div>
</div>
)
}

View File

@ -0,0 +1,19 @@
import { FeedViewType } from "@follow/constants"
import { ArticleLayout } from "./ArticleLayout"
import { PicturesLayout } from "./PicturesLayout"
import { SocialMediaLayout } from "./SocialMediaLayout"
import { VideosLayout } from "./VideosLayout"
const EntryContentLayoutFactory = {
[FeedViewType.Articles]: ArticleLayout,
[FeedViewType.SocialMedia]: SocialMediaLayout,
[FeedViewType.Pictures]: PicturesLayout,
[FeedViewType.Videos]: VideosLayout,
[FeedViewType.Audios]: ArticleLayout, // Use article layout as fallback for audio
[FeedViewType.Notifications]: ArticleLayout, // Use article layout as fallback for notifications
}
export const getEntryContentLayout = (viewType: FeedViewType) => {
return EntryContentLayoutFactory[viewType] || ArticleLayout
}

View File

@ -0,0 +1,5 @@
export { ArticleLayout } from "./ArticleLayout"
export { getEntryContentLayout } from "./factory"
export { PicturesLayout } from "./PicturesLayout"
export { SocialMediaLayout } from "./SocialMediaLayout"
export { VideosLayout } from "./VideosLayout"

View File

@ -0,0 +1,88 @@
import { useEntry } from "@follow/store/entry/hooks"
import { useFeedById } from "@follow/store/feed/hooks"
import { cn } from "@follow/utils/utils"
import { RelativeTime } from "~/components/ui/datetime"
import { parseSocialMedia } from "~/lib/parsers"
import type { FeedIconEntry } from "~/modules/feed/feed-icon"
import { FeedIcon } from "~/modules/feed/feed-icon"
import { FeedTitle } from "~/modules/feed/feed-title"
interface AuthorHeaderProps {
entryId: string
className?: string
showAvatar?: boolean
avatarSize?: number
}
export const AuthorHeader: React.FC<AuthorHeaderProps> = ({
entryId,
className,
showAvatar = true,
avatarSize = 40,
}) => {
const entry = useEntry(entryId, (state) => {
const { feedId, author, authorAvatar, authorUrl, publishedAt, guid, url } = state
const media = state.media || []
const photo = media.find((a) => a.type === "photo")
const firstPhotoUrl = photo?.url
const iconEntry: FeedIconEntry = {
firstPhotoUrl,
authorAvatar,
}
return {
feedId,
author,
authorUrl,
publishedAt,
iconEntry,
guid,
url,
}
})
const feed = useFeedById(entry?.feedId)
if (!entry || !feed) return null
const parsed = parseSocialMedia(entry.authorUrl || entry.url || entry.guid)
return (
<div className={cn("flex items-center gap-2", className)}>
{showAvatar && (
<FeedIcon
fallback
feed={feed}
entry={entry.iconEntry}
size={avatarSize}
className="shrink-0"
/>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1 text-base">
<span className="font-semibold">
<FeedTitle feed={feed} title={entry.author || feed.title} />
</span>
{parsed?.type === "x" && <i className="i-mgc-twitter-cute-fi size-3 text-[#4A99E9]" />}
</div>
<div className="flex items-center gap-1 text-sm text-zinc-500">
{parsed?.type === "x" && (
<>
<a
href={`https://x.com/${parsed.meta.handle}`}
target="_blank"
className="hover:underline"
>
@{parsed.meta.handle}
</a>
<span>·</span>
</>
)}
<RelativeTime date={entry.publishedAt} />
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,59 @@
import { useEntry } from "@follow/store/entry/hooks"
import { cn } from "@follow/utils/utils"
import { HTML } from "~/components/ui/markdown/HTML"
import { readableContentMaxWidthClassName } from "~/constants/ui"
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
interface ContentBodyProps {
entryId: string
className?: string
compact?: boolean
noMedia?: boolean
translation?: {
content?: string
title?: string
}
}
export const ContentBody: React.FC<ContentBodyProps> = ({
entryId,
className,
compact = false,
noMedia = false,
translation,
}) => {
const entry = useEntry(entryId, (state) => ({
content: state.content,
description: state.description,
}))
const renderStyle = useRenderStyle({
baseFontSize: compact ? 14 : 16,
baseLineHeight: compact ? 1.625 : 1.7,
})
if (!entry) return null
const content = translation?.content || entry.content || entry.description
if (!content) return null
return (
<HTML
as="div"
className={cn(
"prose dark:prose-invert",
"prose-blockquote:mt-0",
"cursor-auto select-text",
readableContentMaxWidthClassName,
compact ? "text-sm leading-relaxed" : "text-base leading-relaxed",
className,
)}
noMedia={noMedia}
style={renderStyle}
>
{content}
</HTML>
)
}

View File

@ -0,0 +1,225 @@
import { isMobile } from "@follow/components/hooks/useMobile.js"
import { IN_ELECTRON } from "@follow/shared/constants"
import { useEntry } from "@follow/store/entry/hooks"
import { stopPropagation } from "@follow/utils/dom"
import { formatDuration } from "@follow/utils/duration"
import { transformVideoUrl } from "@follow/utils/url-for-video"
import { cn } from "@follow/utils/utils"
import { useHover } from "@use-gesture/react"
import { useEffect, useMemo, useRef, useState } from "react"
import { AudioPlayer } from "~/atoms/player"
import { m } from "~/components/common/Motion"
import { HTML } from "~/components/ui/markdown/HTML"
import { Media } from "~/components/ui/media/Media"
import type { ModalContentComponent } from "~/components/ui/modal"
import { FixedModalCloseButton } from "~/components/ui/modal/components/close"
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useRenderStyle } from "~/hooks/biz/useRenderStyle"
const ViewTag = IN_ELECTRON ? "webview" : "iframe"
interface VideoPlayerProps {
entryId: string
className?: string
showDuration?: boolean
preferFullSize?: boolean
translation?: {
content?: string
title?: string
}
}
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
entryId,
className,
showDuration = true,
preferFullSize = false,
translation,
}) => {
const entry = useEntry(entryId, (state) => {
const { url, media } = state
const attachments = state.attachments || []
const { duration_in_seconds } =
attachments?.find((attachment) => attachment.duration_in_seconds) ?? {}
const seconds = duration_in_seconds
? Number.parseInt(duration_in_seconds.toString())
: undefined
const duration = formatDuration(seconds)
const firstMedia = media?.[0]
return { attachments, duration, firstMedia, url, media }
})
const [miniIframeSrc, iframeSrc] = useMemo(
() => [
transformVideoUrl({
url: entry?.url ?? "",
mini: true,
isIframe: !IN_ELECTRON,
attachments: entry?.attachments,
}),
transformVideoUrl({
url: entry?.url ?? "",
isIframe: !IN_ELECTRON,
attachments: entry?.attachments,
}),
],
[entry?.attachments, entry?.url],
)
const modalStack = useModalStack()
const ref = useRef<HTMLDivElement>(null)
const [hovered, setHovered] = useState(false)
useHover(
(event) => {
setHovered(event.active)
},
{
target: ref,
},
)
const [showPreview, setShowPreview] = useState(false)
useEffect(() => {
if (hovered) {
const timer = setTimeout(() => {
setShowPreview(true)
}, 500)
return () => clearTimeout(timer)
} else {
setShowPreview(false)
return () => {}
}
}, [hovered])
if (!entry) return null
return (
<div
className={cn("w-full cursor-pointer", className)}
onClick={(e) => {
if (isMobile() && entry.url) {
window.open(entry.url, "_blank")
e.stopPropagation()
return
}
if (iframeSrc) {
modalStack.present({
title: "",
content: (props) => (
<PreviewVideoModalContent
src={iframeSrc}
entryId={entryId}
translation={translation}
{...props}
/>
),
clickOutsideToDismiss: true,
CustomModalComponent: PlainModal,
overlay: true,
})
}
}}
>
<div className="relative aspect-video w-full" ref={ref}>
{preferFullSize && iframeSrc ? (
<ViewTag
src={iframeSrc}
className="aspect-video w-full rounded-md bg-black object-cover"
/>
) : miniIframeSrc && showPreview ? (
<ViewTag
src={miniIframeSrc}
className="pointer-events-none aspect-video w-full rounded-md bg-black object-cover"
/>
) : entry.firstMedia ? (
<Media
key={entry.firstMedia.url}
src={entry.firstMedia.url}
type={entry.firstMedia.type}
previewImageUrl={entry.firstMedia.preview_image_url}
className="aspect-video w-full rounded-md object-cover"
loading="lazy"
proxy={{
width: 640,
height: 360,
}}
showFallback={true}
/>
) : (
<div className="center bg-material-medium text-text-secondary aspect-video w-full flex-col gap-1 rounded-md text-xs">
<i className="i-mgc-sad-cute-re size-6" />
No video available
</div>
)}
{!!entry.duration && showDuration && (
<div className="absolute bottom-2 right-2 rounded-md bg-black/50 px-1 py-0.5 text-xs font-medium text-white">
{entry.duration}
</div>
)}
</div>
</div>
)
}
const PreviewVideoModalContent: ModalContentComponent<{
src: string
entryId: string
translation?: {
content?: string
title?: string
}
}> = ({ dismiss, src, entryId, translation }) => {
const entry = useEntry(entryId, (state) => ({ content: state.content }))
const content = translation?.content || entry?.content
const currentAudioPlayerIsPlay = useRef(AudioPlayer.get().status === "playing")
const renderStyle = useRenderStyle()
useEffect(() => {
const currentValue = currentAudioPlayerIsPlay.current
if (currentValue) {
AudioPlayer.pause()
}
return () => {
if (currentValue) {
AudioPlayer.play()
}
}
}, [])
return (
<m.div exit={{ scale: 0.94, opacity: 0 }} className="size-full p-12" onClick={() => dismiss()}>
<m.div
onFocusCapture={stopPropagation}
initial={true}
exit={{
opacity: 0,
}}
className="safe-inset-top-4 fixed right-4 flex items-center"
>
<FixedModalCloseButton onClick={dismiss} />
</m.div>
<ViewTag src={src} className="size-full" />
{!!content && (
<div className="bg-background p-10 pt-5 backdrop-blur-sm">
<HTML
as="div"
className="prose dark:prose-invert !max-w-full"
noMedia
style={renderStyle}
>
{content}
</HTML>
</div>
)}
</m.div>
)
}

View File

@ -0,0 +1,3 @@
export { AuthorHeader } from "./AuthorHeader"
export { ContentBody } from "./ContentBody"
export { VideoPlayer } from "./VideoPlayer"

View File

@ -13,6 +13,14 @@ import { useMemo } from "react"
const { Avatar, AvatarFallback, AvatarImage } = AvatarPrimitive
// Size-responsive border radius utility function
const getBorderRadius = (size: number) => {
if (size <= 24) return "rounded-sm" // 2px for small avatars
if (size <= 32) return "rounded-md" // 6px for medium avatars
if (size <= 48) return "rounded-lg" // 8px for large avatars
return "rounded-xl" // 12px for extra large avatars
}
function getIconProps(
props: Pick<
Parameters<typeof FeedIcon>[0],
@ -280,7 +288,7 @@ export function FeedIcon({
if (finalSrc) {
return (
<Avatar className={cn("shrink-0 [&_*]:select-none", marginClassName)} style={sizeStyle}>
<AvatarImage className="rounded-sm object-cover" asChild src={finalSrc}>
<AvatarImage className={cn("object-cover", getBorderRadius(size))} asChild src={finalSrc}>
{imageElement}
</AvatarImage>
<AvatarFallback delayMs={200} asChild>

View File

@ -117,15 +117,21 @@ const Root = ({
ref: forwardedRef,
className,
children,
flex,
...rest
}: React.ComponentPropsWithoutRef<typeof ScrollAreaBase.Root> & {
ref?: React.Ref<React.ElementRef<typeof ScrollAreaBase.Root> | null>
flex?: boolean
}) => (
<ScrollAreaBase.Root
{...rest}
scrollHideDelay={0}
ref={forwardedRef}
className={cn("overflow-hidden", className)}
className={cn(
"overflow-hidden",
flex && "min-h-0", // Add explicit min-height for flex contexts
className,
)}
>
{children}
<Corner />
@ -168,11 +174,14 @@ export const ScrollArea = ({
return (
<ScrollElementContext value={viewportRef}>
<ScrollElementEventsContext value={events}>
<Root className={rootClassName}>
<Root className={rootClassName} flex={flex}>
<Viewport
ref={setViewportRef}
onWheel={stopWheelPropagation ? stopPropagation : undefined}
className={cn(flex ? "[&>div]:!flex [&>div]:!flex-col" : "", viewportClassName)}
className={cn(
flex && "[&>div]:!flex [&>div]:!min-h-0 [&>div]:!flex-col", // Add min-h-0 to flex children
viewportClassName,
)}
mask={mask}
asChild={asChild}
onScroll={onScroll}