diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/Items/all-item.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/Items/all-item.tsx index de308c1db..d06520a2a 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/Items/all-item.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/Items/all-item.tsx @@ -1,491 +1,292 @@ -import { views } from "@follow/constants" -import { IN_ELECTRON } from "@follow/shared/constants" -import { useIsEntryStarred } from "@follow/store/collection/hooks" +import { Skeleton } from "@follow/components/ui/skeleton/index.jsx" +import { + Tooltip, + TooltipContent, + TooltipPortal, + TooltipRoot, + TooltipTrigger, +} from "@follow/components/ui/tooltip/index.js" +import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js" +import { useCollectionEntry, useIsEntryStarred } from "@follow/store/collection/hooks" import { useEntry } from "@follow/store/entry/hooks" -import { useEntryStore } from "@follow/store/entry/store" +import type { EntryModel } from "@follow/store/entry/types" import { useFeedById } from "@follow/store/feed/hooks" -import { cn, formatDuration, transformVideoUrl } from "@follow/utils" -import { FeedViewType } from "@follow-app/client-sdk" -import { useHover } from "@use-gesture/react" -import dayjs from "dayjs" -import { useCallback, useMemo, useRef, useState } from "react" -import { useTranslation } from "react-i18next" +import { useInboxById } from "@follow/store/inbox/hooks" +import { cn, isSafari } from "@follow/utils/utils" +import { useMemo } from "react" +import { titleCase } from "title-case" -import { usePreviewMedia } from "~/components/ui/media/hooks" +import { useGeneralSettingKey } from "~/atoms/settings/general" +import { useUISettingKey } from "~/atoms/settings/ui" +import { RelativeTime } from "~/components/ui/datetime" import { Media } from "~/components/ui/media/Media" -import { SwipeMedia } from "~/components/ui/media/SwipeMedia" +import { FEED_COLLECTION_LIST } from "~/constants" import { useEntryIsRead } from "~/hooks/biz/useAsRead" import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams" -import { EntryContent } from "~/modules/entry-content/components/entry-content" +import { EntryTranslation } from "~/modules/entry-column/translation" import type { FeedIconEntry } from "~/modules/feed/feed-icon" import { FeedIcon } from "~/modules/feed/feed-icon" import { FeedTitle } from "~/modules/feed/feed-title" +import { getPreferredTitle } from "~/store/feed/hooks" import { StarIcon } from "../star-icon" -import { EntryTranslation } from "../translation" -import type { EntryListItemFC } from "../types" +import { readableContentMaxWidth } from "../styles" +import type { EntryItemStatelessProps, UniversalItemProps } from "../types" +import { MediaGallery } from "./media-gallery" -const cardStylePresets = [ - { - card: "bg-[#7C6E63] text-white", - icon: "text-[#B7ADA4]", - }, - { - card: "bg-[#D7FED3] text-black", - icon: "text-[#92E58A]", - }, - { - card: "bg-[#FDFFDA] text-black", - icon: "text-[#F9EFAA]", - }, - { - card: "bg-[#FFE5EE] text-black", - icon: "text-[#FDD1E2]", - }, - { - card: "bg-[#CFF0FF] text-black", - icon: "text-[#A7D6F2]", - }, - { - card: "bg-[#ECE7FB] text-black", - icon: "text-[#DFCFF0]", - }, -] +const entrySelector = (state: EntryModel) => { + const { feedId, inboxHandle, read } = state + const { authorAvatar, authorUrl, description, publishedAt, title } = state -const highlightStyle = [ - { - type: "underline", - className: "underline decoration-blue-500 decoration-4 underline-offset-1", - }, - { - type: "underline", - className: "underline decoration-yellow-500 decoration-4 underline-offset-1", - }, - { - type: "underline", - className: "underline decoration-green-500 decoration-4 underline-offset-1", - }, - { - type: "underline", - className: "underline decoration-orange-500 decoration-4 underline-offset-1", - }, - // { - // type: "underline-wavy", - // className: - // "underline decoration-blue-500 decoration-4 underline-offset-1 decoration-wavy", - // }, - // { - // type: "underline-wavy", - // className: - // "underline decoration-yellow-500 decoration-4 underline-offset-1 decoration-wavy", - // }, - // { - // type: "underline-wavy", - // className: - // "underline decoration-green-500 decoration-4 underline-offset-1 decoration-wavy", - // }, - // { - // type: "underline-wavy", - // className: - // "underline decoration-orange-500 decoration-4 underline-offset-1 decoration-wavy", - // }, - { - type: "full", - className: "bg-blue-200", - }, - { - type: "full", - className: "bg-yellow-200", - }, - { - type: "full", - className: "bg-green-200", - }, - { - type: "full", - className: "bg-orange-200", - }, -] + const audios = state.attachments?.filter((a) => a.mime_type?.startsWith("audio") && a.url) + const firstAudio = audios?.[0] + const media = state.media || [] + const firstMedia = media?.[0] + const photo = media.find((a) => a.type === "photo") + const firstPhotoUrl = photo?.url + const iconEntry: FeedIconEntry = { firstPhotoUrl, authorAvatar } -const ViewTag = IN_ELECTRON ? "webview" : "iframe" + const titleEntry = { authorUrl } -export const AllItem: EntryListItemFC = ({ entryId, entryPreview, translation }) => { - const view = useViewTypeByEntryId(entryId) - const entry = useEntry(entryId, (state) => { - /// keep-sorted - const { - attachments, - authorAvatar, - content, - description, - extra, - feedId, - id, - publishedAt, - read, - title, - url, - } = state - - const media = state.media || [] - const photo = media.find((a) => a.type === "photo") - const firstPhotoUrl = photo?.url - const iconEntry: FeedIconEntry = { - firstPhotoUrl, - authorAvatar, - } - - 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) - - return { - attachments, - duration, - content, - extra, - feedId, - iconEntry, - id, - media, - publishedAt, - read, - title, - description, - url, - } - }) + return { + description, + feedId, + firstAudio, + firstMedia, + iconEntry, + inboxId: inboxHandle, + publishedAt, + read, + title, + titleEntry, + } +} +export function AllItem({ entryId, entryPreview, translation }: UniversalItemProps) { + const entry = useEntry(entryId, entrySelector) + const simple = true const isInCollection = useIsEntryStarred(entryId) + const collectionCreatedAt = useCollectionEntry(entryId)?.createdAt - const feeds = useFeedById(entry?.feedId) + const isRead = useEntryIsRead(entry) - const asRead = useEntryIsRead(entry) + const inInCollection = useRouteParamsSelector((s) => s.feedId === FEED_COLLECTION_LIST) - const { t } = useTranslation("common") - - const icon = useMemo(() => views.find((v) => v.view === view)?.icon, [view]) - - const entryMedia = useMemo( - () => entry?.media || entryPreview?.entries?.media || [], - [entry, entryPreview], - ) - - const randomStyle = useMemo(() => { - // Use a hash of entryId to get a consistent index for card style - // djb2 hash - let hash = 5381 - for (let i = 0, len = entryId.length; i < len; ++i) { - hash = (hash << 5) + hash + entryId.codePointAt(i)! - } - - const hashShift = (hash >>> 0) + 1 - - const cardIndex = hashShift % cardStylePresets.length - const highlightIndex = hashShift % highlightStyle.length - - return { - card: cardStylePresets[cardIndex]!, - highlight: highlightStyle[highlightIndex]!, - } - }, [entryId]) - - const isActive = useRouteParamsSelector(({ entryId }) => entryId === entry?.id) - - const entryContent = useMemo(() => , [entryId]) - const previewMedia = usePreviewMedia(entryContent) - - 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 hoverTimerRef = useRef | null>(null) - const [showPreview, setShowPreview] = useState(false) - useHover( - (event) => { - const hovered = event.active - if (hovered) { - if (hoverTimerRef.current) { - clearTimeout(hoverTimerRef.current) - } - hoverTimerRef.current = setTimeout(() => { - setShowPreview(true) - }, 500) - } else { - setShowPreview(false) - if (hoverTimerRef.current) { - clearTimeout(hoverTimerRef.current) - hoverTimerRef.current = null - } + const feed = + useFeedById(entry?.feedId, (feed) => { + return { + type: feed.type, + ownerUserId: feed.ownerUserId, + id: feed.id, + title: feed.title, + url: (feed as any).url || "", + image: feed.image, + siteUrl: feed.siteUrl, } - }, - { target: ref }, - ) + }) || entryPreview?.feeds - const title = entry?.title || entry?.description || entry?.content - const titleKeyword = entry?.extra?.title_keyword?.toLowerCase().trim() || "" + const inbox = useInboxById(entry?.inboxId) - const titleWithKeyword = useMemo(() => { - if (!title || !titleKeyword) return title + const thumbnailRatio = useUISettingKey("thumbnailRatio") + const rid = `list-item-${entryId}` - const regex = new RegExp(`(${titleKeyword.replaceAll(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi") + const bilingual = useGeneralSettingKey("translationMode") === "bilingual" + const lineClamp = useMemo(() => { + const envIsSafari = isSafari() + let lineClampTitle = 1 + let lineClampDescription = 2 - const renderTitle = ({ type }: { type: "highlight" | "normal" }) => ( - <> - {title.split(regex).map((part, index) => { - // Check if this part matches the keyword (case-insensitive) - const normalizedPart = part.toLowerCase().trim() - const normalizedKeyword = titleKeyword.trim() - const isKeyword = normalizedPart === normalizedKeyword && part.trim() !== "" + if (translation?.title && !simple && bilingual) { + lineClampTitle += 1 + } + if (translation?.description && !simple && bilingual) { + lineClampDescription += 1 + } - return isKeyword ? ( - - {part} - - ) : ( - - {part} - - ) - })} - - ) + // FIXME: Safari bug, not support line-clamp cross elements + return { + global: !envIsSafari + ? `line-clamp-[${simple ? lineClampTitle : lineClampTitle + lineClampDescription}]` + : "", + title: envIsSafari ? `line-clamp-[${lineClampTitle}]` : "", + description: envIsSafari ? `line-clamp-[${lineClampDescription}]` : "", + } + }, [simple, translation?.description, translation?.title, bilingual]) - return ( -
- {renderTitle({ type: "normal" })} -
{renderTitle({ type: "highlight" })}
-
- ) - }, [randomStyle.highlight.className, titleKeyword, title]) + const dimRead = useGeneralSettingKey("dimRead") + // NOTE: prevent 0 height element, react virtuoso will not stop render any more + if (!entry || !(feed || inbox)) return null - if (!entry) return null + const displayTime = inInCollection ? collectionCreatedAt : entry?.publishedAt - const mediaCover = entryMedia?.[0] ?? null - - const mediaCoverHeight = mediaCover?.height - const mediaCoverWidth = mediaCover?.width - - const aspectRatio = - mediaCoverHeight && mediaCoverWidth ? mediaCoverWidth / mediaCoverHeight : undefined + const related = feed || inbox return ( -
- {/* Hero */} -
+ +
+
+ + + +
+ {entry.firstMedia && ( + + + + + + + +
+ +
+
+
+
+
)} - > - {/* Icon */} - {!mediaCover && ( + {/* TODO */} + {/* {hasAudio && entry.firstAudio && ( + + } + feed={feed || inbox} + entry={entry?.iconEntry} + size={80} + className="m-0 rounded" + useMedia + noMargin + /> + } + /> + )} */} +
+ {entry?.title ? ( + + ) : ( + + )} + {!!isInCollection && } +
+ {!simple && (
- {icon} -
- )} - - {/* Common views */} - {(view === FeedViewType.Articles || - view === FeedViewType.Notifications || - view === FeedViewType.SocialMedia || - view === FeedViewType.Audios) && ( - <> - {mediaCover ? ( - - ) : ( -
-
{titleWithKeyword}
-
- )} - - )} - - {/* Pictures */} - {view === FeedViewType.Pictures && ( -
- {entryMedia ? ( - - ) : ( -
-
{titleWithKeyword}
-
- )} -
- )} - - {/* Videos */} - {view === FeedViewType.Videos && ( -
-
- {mediaCover ? ( - - ) : ( -
-
{titleWithKeyword}
-
- )} - {miniIframeSrc && showPreview && ( -
- -
- )} - {!!entry.duration && ( -
- {entry.duration} -
- )} -
+
)}
- {/* Footer */} -
-
-
-
- +
+ {!!displayTime && } +
+
+ ) +} - {isInCollection && ( -
- -
- )} -
+AllItem.wrapperClassName = "pl-7 pr-5" + +export function AllItemStateLess({ entry, feed }: EntryItemStatelessProps) { + return ( +
+ +
+
+ + · + {!!entry.publishedAt && }
-
-
- - - - -
- - - {dayjs.duration(dayjs(entry.publishedAt).diff(dayjs(), "minute"), "minute").humanize()} - {t("space")} - {t("words.ago")} - +
+ {entry.title}
) } -AllItem.wrapperClassName = "hover:bg-transparent" - -// function AllArticleItem({ entryId, entryPreview, translation }: UniversalItemProps) { -// return -// } - -// Determine the most appropriate view type for an entry -function useViewTypeByEntryId(entryId: string): FeedViewType { - return useEntryStore( - useCallback( - (state) => { - const certain = Object.entries(state.entryIdByView).find(([_, entryIds]) => - entryIds.has(entryId), - )?.[0] as FeedViewType | undefined - const fallback = FeedViewType.Articles - return Number(certain ?? fallback) - }, - [entryId], - ), - ) -} +export const AllItemSkeleton = ( +
+
+ +
+
+ + · + +
+
+ + +
+
+
+
+) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/Items/all-masonry.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/Items/all-masonry.tsx deleted file mode 100644 index d9c7839dc..000000000 --- a/apps/desktop/layer/renderer/src/modules/entry-column/Items/all-masonry.tsx +++ /dev/null @@ -1,365 +0,0 @@ -import { Masonry } from "@follow/components/ui/masonry/index.js" -import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js" -import { Skeleton } from "@follow/components/ui/skeleton/index.jsx" -import { FeedViewType } from "@follow/constants" -import { useRefValue } from "@follow/hooks" -import { getEntry } from "@follow/store/entry/getter" -import { clsx } from "@follow/utils/utils" -import { ErrorBoundary } from "@sentry/react" -import type { RenderComponentProps } from "masonic" -import { useInfiniteLoader } from "masonic" -import type { FC, ReactNode } from "react" -import { startTransition, useCallback, useEffect, useMemo, useRef, useState } from "react" - -import { useGeneralSettingKey } from "~/atoms/settings/general" - -import { EntryColumnShortcutHandler } from "../EntryColumnShortcutHandler" -import { batchMarkRead } from "../hooks/useEntryMarkReadHandler" -import { EntryItem } from "../item" - -interface AllMasonryProps { - data: string[] - hasNextPage: boolean - endReached: () => void - Footer?: FC | ReactNode - refetch: () => void -} - -const GUTTER = 16 -const COLUMN_WIDTH = 230 -const OVERSCAN = 2 - -interface MasonryItem { - entryId: string -} - -export const AllMasonry: FC = ({ - data, - hasNextPage, - endReached, - Footer, - refetch, -}) => { - const scrollElement = useScrollViewElement() - const [containerRef, setContainerRef] = useState(null) - const [width, setWidth] = useState(0) - const [isLoadingMore, setIsLoadingMore] = useState(false) - const prevDataLengthRef = useRef(data.length) - - // Convert entry IDs to masonry items with stable references - const items = useMemo( - () => data.filter(Boolean).map((entryId) => ({ entryId })), - [data], - ) - - // Handle loading state when new data arrives - useEffect(() => { - if (data.length > prevDataLengthRef.current) { - setIsLoadingMore(false) - } - prevDataLengthRef.current = data.length - }, [data.length]) - - // Force remount when errors happen - const [forceRemountCounter, setForceRemountCounter] = useState(0) - const masonryKey = useMemo(() => `masonry-${forceRemountCounter}`, [forceRemountCounter]) - - // Handle container resize - useEffect(() => { - if (!containerRef) return - - const resizeObserver = new ResizeObserver((entries) => { - const [first] = entries - if (first) { - startTransition(() => { - setWidth(first.contentRect.width) - }) - } - }) - - resizeObserver.observe(containerRef) - return () => resizeObserver.disconnect() - }, [containerRef]) - - const columnCount = useMemo(() => { - if (!width) return 1 - return Math.max(1, Math.round(width / COLUMN_WIDTH)) - }, [width]) - - const columnWidth = useMemo(() => { - if (!width) return COLUMN_WIDTH - const totalGutter = (columnCount - 1) * GUTTER - return Math.floor((width - totalGutter) / columnCount) - }, [width, columnCount]) - - const maybeLoadMore = useInfiniteLoader( - useCallback(() => { - if (hasNextPage && !isLoadingMore) { - setIsLoadingMore(true) - endReached() - } - }, [hasNextPage, endReached, isLoadingMore]), - { - isItemLoaded: (index, items) => index < items.length && Boolean(items[index]), - minimumBatchSize: 24, - threshold: 6, - }, - ) - - const currentRange = useRef<{ start: number; end: number } | undefined>(undefined) - const handleRender = useCallback( - (startIndex: number, stopIndex: number, items: MasonryItem[]) => { - currentRange.current = { start: startIndex, end: stopIndex } - return maybeLoadMore(startIndex, stopIndex, items as any[]) - }, - [maybeLoadMore], - ) - - // Mark as read functionality - const renderMarkRead = useGeneralSettingKey("renderMarkUnread") - const scrollMarkRead = useGeneralSettingKey("scrollMarkUnread") - const dataRef = useRefValue(data) - - useEffect(() => { - if (!renderMarkRead && !scrollMarkRead) return - if (!scrollElement) return - - const observer = new IntersectionObserver( - (entries) => { - if (renderMarkRead) { - const visibleEntryIds: string[] = [] - entries.forEach( - ({ isIntersecting, intersectionRatio, boundingClientRect, rootBounds, target }) => { - if ( - isIntersecting && - intersectionRatio >= 0.8 && - boundingClientRect.top >= (rootBounds?.top ?? 0) - ) { - const { entryId } = (target as HTMLElement).dataset - if (entryId) visibleEntryIds.push(entryId) - } - }, - ) - if (visibleEntryIds.length > 0) { - batchMarkRead(visibleEntryIds) - } - } - - if (scrollMarkRead) { - let minIndex = Number.MAX_SAFE_INTEGER - entries.forEach(({ isIntersecting, boundingClientRect, target }) => { - if (!isIntersecting && boundingClientRect.top < 0) { - const { index: datasetIndex } = (target as HTMLElement).dataset - const parsedIndex = Number.parseInt(datasetIndex || "0") - if (parsedIndex > 0 && parsedIndex <= (currentRange.current?.end ?? 0)) { - minIndex = Math.min(minIndex, parsedIndex) - } - } - }) - if (minIndex !== Number.MAX_SAFE_INTEGER) { - batchMarkRead(dataRef.current.slice(0, minIndex)) - } - } - }, - { - root: scrollElement, - rootMargin: "0px", - threshold: [0, 0.8, 1], - }, - ) - - return () => observer.disconnect() - }, [scrollElement, renderMarkRead, scrollMarkRead, dataRef]) - - const handleScrollTo = useCallback( - (index: number) => { - if (!scrollElement) return - - const findTarget = (): HTMLElement | null => { - const byIndex = containerRef?.querySelector(`[data-index="${index}"]`) - if (byIndex) return byIndex - const id = dataRef.current[index] - if (!id) return null - return containerRef?.querySelector(`[data-entry-id="${id}"]`) ?? null - } - - const scrollToEl = (el: HTMLElement) => { - const scRect = scrollElement.getBoundingClientRect() - const elRect = el.getBoundingClientRect() - const centerOffset = (scrollElement.clientHeight - elRect.height) / 2 - const targetTop = elRect.top - scRect.top + scrollElement.scrollTop - centerOffset - const nextTop = Math.max(0, Math.round(targetTop)) - if (Math.abs(nextTop - scrollElement.scrollTop) > 2) { - scrollElement.scrollTo({ top: nextTop, behavior: "auto" }) - } - } - - const el = findTarget() - if (el) { - scrollToEl(el) - } else { - // Try once more on the next frame in case virtualization just mounted it - requestAnimationFrame(() => { - const el2 = findTarget() - if (el2) scrollToEl(el2) - }) - } - }, - [containerRef, dataRef, scrollElement], - ) - - if (!width) { - return ( -
- -
- ) - } - - return ( -
- { - setForceRemountCounter((prev) => prev + 1) - }} - /> -
- {Footer &&
{typeof Footer === "function" ?
: Footer}
} - {(hasNextPage || isLoadingMore) && } -
- - -
- ) -} - -const itemKey = (item: MasonryItem, index: number) => { - if (!item || !item.entryId) { - console.warn("Missing item or entryId at index:", index) - return `fallback-${index}` - } - return item.entryId -} - -const MasonryItemRender: React.ComponentType> = ({ - data, - index, -}) => { - if (!data || !data.entryId) return - - const entry = getEntry(data.entryId) - if (!entry) return - - return ( -
- -
- ) -} - -const MasonryWrapper: FC<{ - items: MasonryItem[] - columnGutter: number - columnWidth: number - columnCount: number - overscanBy: number - render: React.ComponentType> - onRender: (startIndex: number, stopIndex: number, items: MasonryItem[]) => void - itemKey: (item: MasonryItem, index: number) => string - itemHeightEstimate?: number - onError?: () => void -}> = (props) => { - const [errorKey, setErrorKey] = useState(0) - const [hasError, setHasError] = useState(false) - - useEffect(() => { - if (hasError) { - const timer = setTimeout(() => setHasError(false), 100) - return () => clearTimeout(timer) - } - }, [hasError]) - - return ( - { - console.error("Masonry error caught:", errorData.error) - setHasError(true) - props.onError?.() - return ( -
- -
- ) - }} - beforeCapture={() => { - setErrorKey((prev) => prev + 1) - }} - > - {!hasError && ( - - )} -
- ) -} - -// Loading skeleton component -const LoadingSkeleton: FC<{ count?: number }> = ({ count = 1 }) => { - const keys = useMemo(() => Array.from({ length: count }), [count]) - return ( - <> - {keys.map((_, index) => ( -
-
- - - -
-
- ))} - - ) -} - -const SkeletonGrid: FC<{ columnCount: number }> = ({ columnCount }) => { - const keys = useMemo(() => Array.from({ length: columnCount * 2 }, () => null), [columnCount]) - return ( -
- {keys.map((_, index) => ( - - ))} -
- ) -} diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/Items/media-gallery.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/Items/media-gallery.tsx new file mode 100644 index 000000000..d546bd76e --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/Items/media-gallery.tsx @@ -0,0 +1,160 @@ +import { useEntry } from "@follow/store/entry/hooks" +import { getImageProxyUrl } from "@follow/utils/img-proxy" +import { cn } from "@follow/utils/utils" +import { useMemo } from "react" + +import { usePreviewMedia } from "~/components/ui/media/hooks" +import { Media } from "~/components/ui/media/Media" +import { jotaiStore } from "~/lib/jotai" + +import { socialMediaContentWidthAtom } from "../atoms/social-media-content-width" + +export const MediaGallery = ({ + entryId, + containerWidth, +}: { + entryId: string + containerWidth?: number +}) => { + const entry = useEntry(entryId, (state) => ({ media: state.media })) + const media = useMemo(() => entry?.media || [], [entry?.media]) + + const previewMedia = usePreviewMedia() + + const isAllMediaSameRatio = useMemo(() => { + let ratio = 0 + for (const m of media) { + if (m?.height && m?.width) { + const currentRatio = m.height / m.width + if (ratio === 0) { + ratio = currentRatio + } else if (ratio !== currentRatio) { + return false + } + } else { + return false + } + } + return true + }, [media]) + + if (media.length === 0) return null + + // all media has same ratio, use horizontal layout + if (isAllMediaSameRatio) { + return ( +
+ {media.map((media, i, mediaList) => { + const style: Partial<{ + width: string + height: string + }> = {} + const boundsWidth = containerWidth || jotaiStore.get(socialMediaContentWidthAtom) + if (media.height && media.width) { + // has 1 picture, max width is container width, but max height is less than window height: 2/3 + if (mediaList.length === 1) { + style.width = `${boundsWidth}px` + style.height = `${(boundsWidth * media.height) / media.width}px` + if (Number.parseInt(style.height) > (window.innerHeight * 2) / 3) { + style.height = `${(window.innerHeight * 2) / 3}px` + style.width = `${(Number.parseInt(style.height) * media.width) / media.height}px` + } + } + // has 2 pictures, max width is container half width, and - gap 8px + else if (mediaList.length === 2) { + style.width = `${(boundsWidth - 8) / 2}px` + style.height = `${(((boundsWidth - 8) / 2) * media.height) / media.width}px` + } + // has over 2 pictures, max width is container 1/3 width + else if (mediaList.length > 2) { + style.width = `${boundsWidth / 3}px` + style.height = `${((boundsWidth / 3) * media.height) / media.width}px` + } + } + + const proxySize = { + width: Number.parseInt(style.width || "0") * 2 || 0, + height: Number.parseInt(style.height || "0") * 2 || 0, + } + return ( + { + e.stopPropagation() + previewMedia( + mediaList.map((m) => ({ + url: m.url, + type: m.type, + blurhash: m.blurhash, + fallbackUrl: + m.preview_image_url ?? getImageProxyUrl({ url: m.url, ...proxySize }), + })), + i, + ) + }} + /> + ) + })} +
+ ) + } + + // all media has different ratio, use grid layout + return ( +
+
= 5 && "grid-cols-3", + )} + > + {media.map((m, i) => { + const proxySize = { + width: 400, + height: 400, + } + + const style = media.length === 3 && i === 2 ? { gridRow: "span 2" } : {} + + return ( + { + e.stopPropagation() + previewMedia( + media.map((m) => ({ + url: m.url, + type: m.type, + blurhash: m.blurhash, + fallbackUrl: + m.preview_image_url ?? getImageProxyUrl({ url: m.url, ...proxySize }), + })), + i, + ) + }} + /> + ) + })} +
+
+ ) +} 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 6451461f2..8719bb54d 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 @@ -4,17 +4,14 @@ import { Skeleton } from "@follow/components/ui/skeleton/index.jsx" import { useIsEntryStarred } from "@follow/store/collection/hooks" import { useEntry } from "@follow/store/entry/hooks" import { useFeedById } from "@follow/store/feed/hooks" -import { getImageProxyUrl } from "@follow/utils/img-proxy" import { LRUCache } from "@follow/utils/lru-cache" import { cn } from "@follow/utils/utils" -import { atom } from "jotai" -import { useLayoutEffect, useMemo, useRef, useState } from "react" +import { useLayoutEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" import { useGeneralSettingKey } from "~/atoms/settings/general" import { RelativeTime } from "~/components/ui/datetime" import { HTML } from "~/components/ui/markdown/HTML" -import { usePreviewMedia } from "~/components/ui/media/hooks" import { Media } from "~/components/ui/media/Media" import { useEntryIsRead } from "~/hooks/biz/useAsRead" import { useRenderStyle } from "~/hooks/biz/useRenderStyle" @@ -24,11 +21,12 @@ import type { FeedIconEntry } from "~/modules/feed/feed-icon" import { FeedIcon } from "~/modules/feed/feed-icon" import { FeedTitle } from "~/modules/feed/feed-title" +import { socialMediaContentWidthAtom } from "../atoms/social-media-content-width" import { StarIcon } from "../star-icon" import { readableContentMaxWidth } from "../styles" import type { EntryItemStatelessProps, EntryListItemFC } from "../types" +import { MediaGallery } from "./media-gallery" -const socialMediaContentWidthAtom = atom(0) export const SocialMediaItem: EntryListItemFC = ({ entryId, translation }) => { const entry = useEntry(entryId, (state) => { const { feedId, read } = state @@ -131,7 +129,7 @@ export const SocialMediaItem: EntryListItemFC = ({ entryId, translation }) => { {isInCollection && }
- +
) @@ -215,150 +213,6 @@ export const SocialMediaItemSkeleton = (
) -export const SocialMediaGallery = ({ entryId }: { entryId: string }) => { - const entry = useEntry(entryId, (state) => ({ media: state.media })) - const media = useMemo(() => entry?.media || [], [entry?.media]) - - const previewMedia = usePreviewMedia() - - const isAllMediaSameRatio = useMemo(() => { - let ratio = 0 - for (const m of media) { - if (m?.height && m?.width) { - const currentRatio = m.height / m.width - if (ratio === 0) { - ratio = currentRatio - } else if (ratio !== currentRatio) { - return false - } - } else { - return false - } - } - return true - }, [media]) - - if (media.length === 0) return null - - // all media has same ratio, use horizontal layout - if (isAllMediaSameRatio) { - return ( -
- {media.map((media, i, mediaList) => { - const style: Partial<{ - width: string - height: string - }> = {} - const boundsWidth = jotaiStore.get(socialMediaContentWidthAtom) - if (media.height && media.width) { - // has 1 picture, max width is container width, but max height is less than window height: 2/3 - if (mediaList.length === 1) { - style.width = `${boundsWidth}px` - style.height = `${(boundsWidth * media.height) / media.width}px` - if (Number.parseInt(style.height) > (window.innerHeight * 2) / 3) { - style.height = `${(window.innerHeight * 2) / 3}px` - style.width = `${(Number.parseInt(style.height) * media.width) / media.height}px` - } - } - // has 2 pictures, max width is container half width, and - gap 8px - else if (mediaList.length === 2) { - style.width = `${(boundsWidth - 8) / 2}px` - style.height = `${(((boundsWidth - 8) / 2) * media.height) / media.width}px` - } - // has over 2 pictures, max width is container 1/3 width - else if (mediaList.length > 2) { - style.width = `${boundsWidth / 3}px` - style.height = `${((boundsWidth / 3) * media.height) / media.width}px` - } - } - - const proxySize = { - width: Number.parseInt(style.width || "0") * 2 || 0, - height: Number.parseInt(style.height || "0") * 2 || 0, - } - return ( - { - e.stopPropagation() - previewMedia( - mediaList.map((m) => ({ - url: m.url, - type: m.type, - blurhash: m.blurhash, - fallbackUrl: - m.preview_image_url ?? getImageProxyUrl({ url: m.url, ...proxySize }), - })), - i, - ) - }} - /> - ) - })} -
- ) - } - - // all media has different ratio, use grid layout - return ( -
-
= 5 && "grid-cols-3", - )} - > - {media.map((m, i) => { - const proxySize = { - width: 400, - height: 400, - } - - const style = media.length === 3 && i === 2 ? { gridRow: "span 2" } : {} - - return ( - { - e.stopPropagation() - previewMedia( - media.map((m) => ({ - url: m.url, - type: m.type, - blurhash: m.blurhash, - fallbackUrl: - m.preview_image_url ?? getImageProxyUrl({ url: m.url, ...proxySize }), - })), - i, - ) - }} - /> - ) - })} -
-
- ) -} - const collapsedHeight = 300 const collapsedItemCache = new LRUCache(100) const CollapsedSocialMediaItem: Component<{ diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/atoms/social-media-content-width.ts b/apps/desktop/layer/renderer/src/modules/entry-column/atoms/social-media-content-width.ts new file mode 100644 index 000000000..caed68564 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/entry-column/atoms/social-media-content-width.ts @@ -0,0 +1,3 @@ +import { atom } from "jotai" + +export const socialMediaContentWidthAtom = atom(0) diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/grid.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/grid.tsx index 100177231..a4ad29aed 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/grid.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/grid.tsx @@ -21,29 +21,15 @@ import { MediaContainerWidthProvider } from "~/components/ui/media/MediaContaine import { EntryItemSkeleton } from "./EntryItemSkeleton" import { EntryItem } from "./item" -import { AllMasonry } from "./Items/all-masonry" import { PictureMasonry } from "./Items/picture-masonry" import type { EntryListProps } from "./list" export const EntryColumnGrid: FC = (props) => { - const { entriesIds, feedId, hasNextPage, view, fetchNextPage, refetch } = props + const { entriesIds, feedId, hasNextPage, view, fetchNextPage } = props const isMobile = useMobile() const masonry = useUISettingKey("pictureViewMasonry") || isMobile - if (view === FeedViewType.All) { - return ( - - ) - } - if (masonry && view === FeedViewType.Pictures) { return ( { ...(hidePrivateSubscriptionsInTimeline === true && { hidePrivateSubscriptionsInTimeline: true, }), + ...(view === FeedViewType.All && { limit: 40 }), } if (feedId && listId && isBizId(feedId)) { @@ -286,7 +287,7 @@ export const useEntriesByView = ({ onReset }: { onReset?: () => void }) => { const groupByDate = useGeneralSettingKey("groupByDate") const groupedCounts: number[] | undefined = useMemo(() => { const viewDefinition = views.find((v) => v.view === view) - if (viewDefinition?.gridMode) { + if (viewDefinition?.gridMode || view === FeedViewType.All) { return } if (!groupByDate) { diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/item-stateless.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/item-stateless.tsx index 6757d2e2a..563d997b1 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/item-stateless.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/item-stateless.tsx @@ -2,6 +2,7 @@ import { FeedViewType } from "@follow/constants" import type { FC } from "react" import { memo } from "react" +import { AllItemStateLess } from "./Items/all-item" import { ArticleItemStateLess } from "./Items/article-item" import { NotificationItemStateLess } from "./Items/notification-item" import { PictureItemStateLess } from "./Items/picture-item-stateless" @@ -10,6 +11,7 @@ import { VideoItemStateLess } from "./Items/video-item" import type { EntryItemStatelessProps } from "./types" const StatelessItemMap = { + [FeedViewType.All]: AllItemStateLess, [FeedViewType.Articles]: ArticleItemStateLess, [FeedViewType.SocialMedia]: SocialMediaItemStateLess, [FeedViewType.Pictures]: PictureItemStateLess, diff --git a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.tsx b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.tsx index 9929179a2..ada22590a 100644 --- a/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.tsx +++ b/apps/desktop/layer/renderer/src/modules/entry-column/layouts/EntryListHeader.tsx @@ -63,7 +63,7 @@ export const EntryListHeader: FC<{ const feed = useFeedById(feedId) const titleStyleBasedView = { - [FeedViewType.All]: "pl-5", + [FeedViewType.All]: "pl-7", [FeedViewType.Articles]: "pl-7", [FeedViewType.Pictures]: "pl-7", [FeedViewType.Videos]: "pl-7", 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 index c961784f7..f2cc2fe55 100644 --- 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 @@ -3,7 +3,7 @@ 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 { MediaGallery } from "~/modules/entry-column/Items/media-gallery" import { AuthorHeader } from "./shared/AuthorHeader" import { ContentBody } from "./shared/ContentBody" @@ -44,7 +44,7 @@ export const SocialMediaLayout: React.FC = ({ /> {/* Media gallery */} - {!noMedia && } + {!noMedia && } ) } 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 353e91a03..dfebab7b6 100644 --- a/apps/desktop/layer/renderer/src/modules/feed/feed-icon.tsx +++ b/apps/desktop/layer/renderer/src/modules/feed/feed-icon.tsx @@ -188,7 +188,7 @@ export function FeedIcon({ disableFadeIn?: boolean noMargin?: boolean }) { - const marginClassName = noMargin ? "" : "mr-2" + const marginClassName = cn(noMargin ? "" : "mr-2", className) const iconProps = getIconProps({ feed, entry, useMedia, siteUrl, fallbackUrl, fallback, size }) const colors = useMemo( @@ -219,7 +219,6 @@ export function FeedIcon({ "flex shrink-0 items-center justify-center rounded-sm", "text-white", marginClassName, - className, )} > {fallbackSrc ? ( ) : ( diff --git a/packages/internal/constants/src/tabs.tsx b/packages/internal/constants/src/tabs.tsx index 1a6ebb20f..29e0097dd 100644 --- a/packages/internal/constants/src/tabs.tsx +++ b/packages/internal/constants/src/tabs.tsx @@ -30,8 +30,6 @@ export const views: ViewDefinition[] = [ peerClassName: "peer-checked:text-folo dark:peer-checked:text-folo", translation: "title,description,content", view: FeedViewType.All, - gridMode: true, - wideMode: true, activeColor: "#FF5C00", switchable: false, },