From c8e3a0e1d4cc7f37feb5c2f977ac0920e79b1943 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Wed, 23 Jul 2025 22:11:18 +0800 Subject: [PATCH] feat: entry content layout for ai --- .../src/components/ui/media/SwipeMedia.tsx | 14 +- .../layer/renderer/src/constants/ui.ts | 2 + .../entry-column/EntryColumnLayout.tsx | 2 +- .../entry-column/Items/picture-item.tsx | 8 +- .../entry-column/Items/social-media-item.tsx | 2 +- .../entry-column/Items/video-item.ai.tsx | 198 +++++++++++ .../entry-column/Items/video-item.legacy.tsx | 304 +++++++++++++++++ .../modules/entry-column/Items/video-item.tsx | 313 +---------------- .../src/modules/entry-column/styles.ts | 4 +- .../entry-content/EntryContent.ai.tsx | 243 ++++++++++++++ .../entry-content/EntryContent.legacy.tsx | 313 +++++++++++++++++ .../components/entry-content/EntryContent.tsx | 315 +----------------- .../components/layouts/ArticleLayout.tsx | 129 +++++++ .../components/layouts/PicturesLayout.tsx | 56 ++++ .../components/layouts/SocialMediaLayout.tsx | 50 +++ .../components/layouts/VideosLayout.tsx | 63 ++++ .../components/layouts/factory.ts | 19 ++ .../entry-content/components/layouts/index.ts | 5 + .../layouts/shared/AuthorHeader.tsx | 88 +++++ .../components/layouts/shared/ContentBody.tsx | 59 ++++ .../components/layouts/shared/VideoPlayer.tsx | 225 +++++++++++++ .../components/layouts/shared/index.ts | 3 + .../renderer/src/modules/feed/feed-icon.tsx | 10 +- .../src/ui/scroll-area/ScrollArea.tsx | 15 +- 24 files changed, 1811 insertions(+), 629 deletions(-) create mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/Items/video-item.ai.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/entry-column/Items/video-item.legacy.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.ai.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.legacy.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/ArticleLayout.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/PicturesLayout.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/SocialMediaLayout.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/VideosLayout.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/factory.ts create mode 100644 apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/index.ts create mode 100644 apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/AuthorHeader.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/ContentBody.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/VideoPlayer.tsx create mode 100644 apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/index.ts diff --git a/apps/desktop/layer/renderer/src/components/ui/media/SwipeMedia.tsx b/apps/desktop/layer/renderer/src/components/ui/media/SwipeMedia.tsx index e8ac3d541..be7a75fb4 100644 --- a/apps/desktop/layer/renderer/src/components/ui/media/SwipeMedia.tsx +++ b/apps/desktop/layer/renderer/src/components/ui/media/SwipeMedia.tsx @@ -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) => (
{ - e.stopPropagation() - onPreview?.(uniqMedia, i) + if (onPreview) { + e.stopPropagation() + onPreview(uniqMedia, i) + } }} showFallback={true} fitContent diff --git a/apps/desktop/layer/renderer/src/constants/ui.ts b/apps/desktop/layer/renderer/src/constants/ui.ts index 62793b760..b193af66a 100644 --- a/apps/desktop/layer/renderer/src/constants/ui.ts +++ b/apps/desktop/layer/renderer/src/constants/ui.ts @@ -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)]" diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnLayout.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnLayout.tsx index 8214905e8..07982c15a 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnLayout.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/EntryColumnLayout.tsx @@ -171,7 +171,7 @@ export const EntryColumnLayout = () => {
- + )} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-item.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-item.tsx index 3e8a214db..a1f0e11d3 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-item.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/Items/picture-item.tsx @@ -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} /> ) : (
@@ -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(() => , [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} />
diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/Items/social-media-item.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/Items/social-media-item.tsx index f8185c2d4..7ca0c2102 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/Items/social-media-item.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/Items/social-media-item.tsx @@ -209,7 +209,7 @@ export const SocialMediaItemSkeleton = (
) -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]) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/Items/video-item.ai.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/Items/video-item.ai.tsx new file mode 100644 index 000000000..8c1d796a9 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/Items/video-item.ai.tsx @@ -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(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 ( + +
+
+ {miniIframeSrc && showPreview ? ( + + ) : entry.firstMedia ? ( + + ) : ( +
+ + No media available +
+ )} + {!!entry.duration && ( +
+ {entry.duration} +
+ )} +
+
+
+ ) +} + +export function VideoItemStateLess({ entry, feed }: EntryItemStatelessProps) { + return ( +
+
+
+
+
+ {entry.media?.[0] ? ( + + ) : ( + + )} +
+
+
+
+ {entry.title} +
+
+ + + · + {!!entry.publishedAt && } +
+
+
+
+
+ ) +} + +export const VideoItemSkeleton = ( +
+
+
+
+
+ +
+
+
+
+ +
+
+ + + · + +
+
+
+
+
+) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/Items/video-item.legacy.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/Items/video-item.legacy.tsx new file mode 100644 index 000000000..c520ad416 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/Items/video-item.legacy.tsx @@ -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(() => , [entryId]) + const previewMedia = usePreviewMedia(entryContent) + + const ref = useRef(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 ( + +
{ + if (isMobile() && entry.url) { + window.open(entry.url, "_blank") + e.stopPropagation() + return + } + if (iframeSrc) { + modalStack.present({ + title: "", + content: (props) => ( + + ), + clickOutsideToDismiss: true, + CustomModalComponent: PlainModal, + overlay: true, + }) + } else { + previewMedia(entry.media) + } + }} + > +
+ {miniIframeSrc && showPreview ? ( + + ) : entry.firstMedia ? ( + + ) : ( +
+ + No media available +
+ )} + {!!entry.duration && ( +
+ {entry.duration} +
+ )} +
+
+
+ ) +} + +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 ( + dismiss()}> + + + + + + {!!content && ( +
+ + {content} + +
+ )} +
+ ) +} + +export function VideoItemStateLess({ entry, feed }: EntryItemStatelessProps) { + return ( +
+
+
+
+
+ {entry.media?.[0] ? ( + + ) : ( + + )} +
+
+
+
+ {entry.title} +
+
+ + + · + {!!entry.publishedAt && } +
+
+
+
+
+ ) +} + +export const VideoItemSkeleton = ( +
+
+
+
+
+ +
+
+
+
+ +
+
+ + + · + +
+
+
+
+
+) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/Items/video-item.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/Items/video-item.tsx index c520ad416..8b46f0947 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/Items/video-item.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/Items/video-item.tsx @@ -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(() => , [entryId]) - const previewMedia = usePreviewMedia(entryContent) - - const ref = useRef(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 ( - -
{ - if (isMobile() && entry.url) { - window.open(entry.url, "_blank") - e.stopPropagation() - return - } - if (iframeSrc) { - modalStack.present({ - title: "", - content: (props) => ( - - ), - clickOutsideToDismiss: true, - CustomModalComponent: PlainModal, - overlay: true, - }) - } else { - previewMedia(entry.media) - } - }} - > -
- {miniIframeSrc && showPreview ? ( - - ) : entry.firstMedia ? ( - - ) : ( -
- - No media available -
- )} - {!!entry.duration && ( -
- {entry.duration} -
- )} -
-
-
- ) -} - -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 ( - dismiss()}> - - - - - - {!!content && ( -
- - {content} - -
- )} -
- ) -} - -export function VideoItemStateLess({ entry, feed }: EntryItemStatelessProps) { - return ( -
-
-
-
-
- {entry.media?.[0] ? ( - - ) : ( - - )} -
-
-
-
- {entry.title} -
-
- - - · - {!!entry.publishedAt && } -
-
-
-
-
- ) -} - -export const VideoItemSkeleton = ( -
-
-
-
-
- -
-
-
-
- -
-
- - - · - -
-
-
-
-
-) +export { VideoItemSkeleton } from "./video-item.ai" diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/styles.ts b/apps/desktop/layer/renderer/src/modules/entry-column/styles.ts index c53559357..b69e4fa40 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/styles.ts +++ b/apps/desktop/layer/renderer/src/modules/entry-column/styles.ts @@ -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` diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.ai.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.ai.tsx new file mode 100644 index 000000000..10c93cee5 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.ai.tsx @@ -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 = ({ + 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(null) + const safeUrl = useFeedSafeUrl(entryId) + + const isInPeekModal = useInPeekModal() + const isZenMode = useIsZenMode() + + const [panelPortalElement, setPanelPortalElement] = useState(null) + + const animationController = useAnimationControls() + const prevEntryId = useRef(undefined) + const scrollAnimationRef = useRef | 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 ( +
+ + {!isInPeekModal && ( + + )} +
+ + + + + + + + {/* Indicator for the entry */} + + {!isZenMode && isInHasTimelineView && !isInPeekModal && ( + <> +
+ { + EventBus.dispatch(COMMAND_ID.timeline.switchToPrevious) + }} + > + + +
+ +
+ { + EventBus.dispatch(COMMAND_ID.timeline.switchToNext) + }} + > + + +
+ + )} + +
+ + + {!content && !isInReadabilityMode ? ( +
+ {isPending ? ( + + ) : error ? ( +
+ + Network Error +
+                        {error.message}
+                      
+
+ ) : ( + + )} +
+ ) : ( + + )} +
+
+
+ +
+ + {!isInPeekModal && } +
+ ) +} +export const EntryContent = memo(EntryContentImpl) + +const EntryScrollArea: Component<{ + scrollerRef: React.RefObject +}> = ({ children, className, scrollerRef }) => { + const isInPeekModal = useInPeekModal() + + if (isInPeekModal) { + return
{children}
+ } + return ( + + {children} + + ) +} + +const AdaptiveContentRenderer: React.FC<{ + entryId: string + view: FeedViewType + compact?: boolean + noMedia?: boolean +}> = ({ entryId, view, compact = false, noMedia = false }) => { + const LayoutComponent = getEntryContentLayout(view) + + return +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.legacy.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.legacy.tsx new file mode 100644 index 000000000..0e8f05596 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.legacy.tsx @@ -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 = ({ + 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(null) + const safeUrl = useFeedSafeUrl(entryId) + const customCSS = useUISettingKey("customCSS") + + const isInPeekModal = useInPeekModal() + const isZenMode = useIsZenMode() + + const [panelPortalElement, setPanelPortalElement] = useState(null) + + const animationController = useAnimationControls() + const prevEntryId = useRef(undefined) + const scrollAnimationRef = useRef | 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 ( + <> + + {!isInPeekModal && ( + + )} +
+ + + + + + + + {/* Indicator for the entry */} + + {!isZenMode && isInHasTimelineView && !isInPeekModal && ( + <> +
+ { + EventBus.dispatch(COMMAND_ID.timeline.switchToPrevious) + }} + > + + +
+ +
+ { + EventBus.dispatch(COMMAND_ID.timeline.switchToNext) + }} + > + + +
+ + )} + +
+ + + +
+ + + + + + {!!customCSS && ( + {customCSS} + )} + + + + +
+
+ + + + {!content && !isInReadabilityMode && ( +
+ {isPending ? ( + + ) : error ? ( +
+ + Network Error +
+                        {error.message}
+                      
+
+ ) : ( + + )} +
+ )} + + + +
+
+
+ +
+ + {!isInPeekModal && } + + ) +} +export const EntryContent = memo(EntryContentImpl) + +const EntryScrollArea: Component<{ + scrollerRef: React.RefObject +}> = ({ children, className, scrollerRef }) => { + const isInPeekModal = useInPeekModal() + + if (isInPeekModal) { + return
{children}
+ } + return ( + + {children} + + ) +} + +const Renderer: React.FC<{ + entryId: string + view: FeedViewType + feedId: string + noMedia?: boolean + content?: Nullable +}> = 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(null) + const contentAccessories = useMemo( + () => (isInPeekModal ? undefined : ), + [isInPeekModal], + ) + + useEffect(() => { + if (tocRef) { + tocRef.current?.refreshItems() + } + }, [content, tocRef]) + return ( + + {content} + + ) +}) diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.tsx index 0e8f05596..98a99a4b3 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/entry-content/EntryContent.tsx @@ -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 = ({ - 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(null) - const safeUrl = useFeedSafeUrl(entryId) - const customCSS = useUISettingKey("customCSS") - - const isInPeekModal = useInPeekModal() - const isZenMode = useIsZenMode() - - const [panelPortalElement, setPanelPortalElement] = useState(null) - - const animationController = useAnimationControls() - const prevEntryId = useRef(undefined) - const scrollAnimationRef = useRef | 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 ( - <> - - {!isInPeekModal && ( - - )} -
- - - - - - - - {/* Indicator for the entry */} - - {!isZenMode && isInHasTimelineView && !isInPeekModal && ( - <> -
- { - EventBus.dispatch(COMMAND_ID.timeline.switchToPrevious) - }} - > - - -
- -
- { - EventBus.dispatch(COMMAND_ID.timeline.switchToNext) - }} - > - - -
- - )} - -
- - - -
- - - - - - {!!customCSS && ( - {customCSS} - )} - - - - -
-
- - - - {!content && !isInReadabilityMode && ( -
- {isPending ? ( - - ) : error ? ( -
- - Network Error -
-                        {error.message}
-                      
-
- ) : ( - - )} -
- )} - - - -
-
-
- -
- - {!isInPeekModal && } - - ) -} -export const EntryContent = memo(EntryContentImpl) - -const EntryScrollArea: Component<{ - scrollerRef: React.RefObject -}> = ({ children, className, scrollerRef }) => { - const isInPeekModal = useInPeekModal() - - if (isInPeekModal) { - return
{children}
- } - return ( - - {children} - - ) -} - -const Renderer: React.FC<{ - entryId: string - view: FeedViewType - feedId: string - noMedia?: boolean - content?: Nullable -}> = 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(null) - const contentAccessories = useMemo( - () => (isInPeekModal ? undefined : ), - [isInPeekModal], - ) - - useEffect(() => { - if (tocRef) { - tocRef.current?.refreshItems() - } - }, [content, tocRef]) - return ( - - {content} - - ) -}) +export const EntryContent = withFeature("ai")(EntryContentAI, EntryContentLegacy) diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/ArticleLayout.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/ArticleLayout.tsx new file mode 100644 index 000000000..edce5c24b --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/ArticleLayout.tsx @@ -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 = ({ + 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 ( +
+ + + +
+ + + + + + {!!customCSS && {customCSS}} + + + + +
+
+ + + +
+ ) +} + +const Renderer: React.FC<{ + entryId: string + view: FeedViewType + feedId: string + noMedia?: boolean + content?: Nullable + 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(null) + const contentAccessories = useMemo( + () => (isInPeekModal ? undefined : ), + [isInPeekModal], + ) + + return ( + + {translation?.content || content} + + ) +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/PicturesLayout.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/PicturesLayout.tsx new file mode 100644 index 000000000..9897cbeb2 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/PicturesLayout.tsx @@ -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 = ({ + 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 ( +
+ {!noMedia && ( + + )} + + {/* Single Author header without avatar */} + + + {/* Text Content Section */} + +
+ ) +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/SocialMediaLayout.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/SocialMediaLayout.tsx new file mode 100644 index 000000000..ec22a9c53 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/SocialMediaLayout.tsx @@ -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 = ({ + 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 ( +
+ {/* Single Author header without avatar */} + + + {/* Main content - direct ContentBody usage without show more logic */} + + + {/* Media gallery */} + {!noMedia && } +
+ ) +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/VideosLayout.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/VideosLayout.tsx new file mode 100644 index 000000000..0508d13e5 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/VideosLayout.tsx @@ -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 = ({ + entryId, + compact = false, + noMedia = false, + translation, +}) => { + const entry = useEntry(entryId, (state) => state) + + if (!entry) return null + + return ( +
+ {/* Video player area */} +
+ {!noMedia ? ( + + ) : ( +
+ + Video content not available +
+ )} +
+ + {/* Content area below video */} +
+ {/* Title */} + + + {/* Description/Content */} + +
+
+ ) +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/factory.ts b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/factory.ts new file mode 100644 index 000000000..bbf0d26c9 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/factory.ts @@ -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 +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/index.ts b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/index.ts new file mode 100644 index 000000000..70aa601bf --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/index.ts @@ -0,0 +1,5 @@ +export { ArticleLayout } from "./ArticleLayout" +export { getEntryContentLayout } from "./factory" +export { PicturesLayout } from "./PicturesLayout" +export { SocialMediaLayout } from "./SocialMediaLayout" +export { VideosLayout } from "./VideosLayout" diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/AuthorHeader.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/AuthorHeader.tsx new file mode 100644 index 000000000..b2e57cb93 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/AuthorHeader.tsx @@ -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 = ({ + 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 ( +
+ {showAvatar && ( + + )} +
+
+ + + + {parsed?.type === "x" && } +
+
+ {parsed?.type === "x" && ( + <> + + @{parsed.meta.handle} + + · + + )} + +
+
+
+ ) +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/ContentBody.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/ContentBody.tsx new file mode 100644 index 000000000..202612c46 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/ContentBody.tsx @@ -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 = ({ + 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 ( + + {content} + + ) +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/VideoPlayer.tsx b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/VideoPlayer.tsx new file mode 100644 index 000000000..5e7d36f84 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/VideoPlayer.tsx @@ -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 = ({ + 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(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 ( +
{ + if (isMobile() && entry.url) { + window.open(entry.url, "_blank") + e.stopPropagation() + return + } + if (iframeSrc) { + modalStack.present({ + title: "", + content: (props) => ( + + ), + clickOutsideToDismiss: true, + CustomModalComponent: PlainModal, + overlay: true, + }) + } + }} + > +
+ {preferFullSize && iframeSrc ? ( + + ) : miniIframeSrc && showPreview ? ( + + ) : entry.firstMedia ? ( + + ) : ( +
+ + No video available +
+ )} + {!!entry.duration && showDuration && ( +
+ {entry.duration} +
+ )} +
+
+ ) +} + +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 ( + dismiss()}> + + + + + + {!!content && ( +
+ + {content} + +
+ )} +
+ ) +} diff --git a/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/index.ts b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/index.ts new file mode 100644 index 000000000..1acdb549e --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-content/components/layouts/shared/index.ts @@ -0,0 +1,3 @@ +export { AuthorHeader } from "./AuthorHeader" +export { ContentBody } from "./ContentBody" +export { VideoPlayer } from "./VideoPlayer" diff --git a/apps/desktop/layer/renderer/src/modules/feed/feed-icon.tsx b/apps/desktop/layer/renderer/src/modules/feed/feed-icon.tsx index 6ddc2b0f1..3f2f5e1f9 100644 --- a/apps/desktop/layer/renderer/src/modules/feed/feed-icon.tsx +++ b/apps/desktop/layer/renderer/src/modules/feed/feed-icon.tsx @@ -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[0], @@ -280,7 +288,7 @@ export function FeedIcon({ if (finalSrc) { return ( - + {imageElement} diff --git a/packages/internal/components/src/ui/scroll-area/ScrollArea.tsx b/packages/internal/components/src/ui/scroll-area/ScrollArea.tsx index 88b0d9b9e..4e90e1a04 100644 --- a/packages/internal/components/src/ui/scroll-area/ScrollArea.tsx +++ b/packages/internal/components/src/ui/scroll-area/ScrollArea.tsx @@ -117,15 +117,21 @@ const Root = ({ ref: forwardedRef, className, children, + flex, ...rest }: React.ComponentPropsWithoutRef & { ref?: React.Ref | null> + flex?: boolean }) => ( {children} @@ -168,11 +174,14 @@ export const ScrollArea = ({ return ( - + 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}