+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 && }