feat: entry content layout for ai
This commit is contained in:
parent
8b76fa3623
commit
c8e3a0e1d4
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)]"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export { ArticleLayout } from "./ArticleLayout"
|
||||
export { getEntryContentLayout } from "./factory"
|
||||
export { PicturesLayout } from "./PicturesLayout"
|
||||
export { SocialMediaLayout } from "./SocialMediaLayout"
|
||||
export { VideosLayout } from "./VideosLayout"
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export { AuthorHeader } from "./AuthorHeader"
|
||||
export { ContentBody } from "./ContentBody"
|
||||
export { VideoPlayer } from "./VideoPlayer"
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue