feat: compact unified view

This commit is contained in:
DIYgod 2025-09-09 21:36:11 +08:00
parent 280bc2a45e
commit 6c1ee7dc30
No known key found for this signature in database
12 changed files with 423 additions and 984 deletions

View File

@ -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(() => <EntryContent entryId={entryId} noMedia compact />, [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<HTMLDivElement>(null)
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | 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 ? (
<span
key={`keyword-${index}-${part}`}
className={cn(type === "highlight" && randomStyle.highlight.className)}
style={{
textDecorationSkipInk: "none",
}}
>
{part}
</span>
) : (
<span
key={`text-${index}-${part}`}
className={cn(type === "highlight" && "text-transparent")}
>
{part}
</span>
)
})}
</>
)
// 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 (
<div className="relative">
{renderTitle({ type: "normal" })}
<div className="absolute inset-0 z-0">{renderTitle({ type: "highlight" })}</div>
</div>
)
}, [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 (
<div className="group" ref={ref}>
{/* Hero */}
<div
className={cn(
"relative flex max-h-[35em] flex-col overflow-hidden rounded-lg",
"before:group-hover:bg-theme-item-hover before:pointer-events-none before:absolute before:inset-0 before:z-10 before:transition-colors before:duration-200",
randomStyle.card.card,
<div
className={cn(
"cursor-menu group relative flex items-center py-2",
!isRead &&
"before:bg-accent before:absolute before:-left-4 before:top-[14px] before:block before:size-2 before:rounded-full",
)}
>
<FeedIcon feed={related} fallback entry={entry?.iconEntry} size={20} />
<div className={cn("flex h-fit min-w-0 flex-1 flex-row items-center text-sm leading-tight")}>
<div
className={cn(
"mr-4 flex w-20 shrink-0 gap-1 text-xs",
"text-text-secondary",
isInCollection && "text-text-secondary",
isRead && dimRead && "text-text-tertiary",
)}
>
<EllipsisHorizontalTextWithTooltip className="truncate">
<FeedTitle
feed={related}
title={getPreferredTitle(related, entry?.titleEntry)}
className="space-x-0.5"
/>
</EllipsisHorizontalTextWithTooltip>
</div>
{entry.firstMedia && (
<Tooltip>
<TooltipRoot>
<TooltipTrigger asChild>
<Media
thumbnail
src={entry.firstMedia.url}
type={entry.firstMedia.type}
previewImageUrl={entry.firstMedia.preview_image_url}
className={cn("center mr-2 flex shrink-0 rounded", "size-5")}
mediaContainerClassName={"w-auto h-auto rounded-sm"}
loading="lazy"
key={`${rid}-media-${thumbnailRatio}`}
proxy={{
width: 40,
height: 40,
}}
height={entry.firstMedia.height}
width={entry.firstMedia.width}
blurhash={entry.firstMedia.blurhash}
/>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent className="flex-col gap-1" side={"bottom"}>
<div className="flex items-center gap-1">
<MediaGallery entryId={entryId} containerWidth={575} />
</div>
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</Tooltip>
)}
>
{/* Icon */}
{!mediaCover && (
{/* TODO */}
{/* {hasAudio && entry.firstAudio && (
<AudioCover
entryId={entryId}
src={entry.firstAudio.url}
durationInSeconds={entry.firstAudio.duration_in_seconds}
feedIcon={
<FeedIcon
fallback={true}
fallbackElement={
<div className={clsx("bg-material-ultra-thick", "size-[80px]", "rounded")} />
}
feed={feed || inbox}
entry={entry?.iconEntry}
size={80}
className="m-0 rounded"
useMedia
noMargin
/>
}
/>
)} */}
<div
className={cn(
"relative flex min-w-0 items-center truncate break-words",
"text-text",
!!isInCollection && "pr-5",
entry?.title ? "font-medium" : "text-[13px]",
isRead && dimRead && "text-text-secondary",
)}
>
{entry?.title ? (
<EntryTranslation
className={cn(
"inline-flex min-w-0 items-center hyphens-auto font-medium",
lineClamp.title,
)}
source={titleCase(entry?.title ?? "")}
target={titleCase(translation?.title ?? "")}
/>
) : (
<EntryTranslation
className={cn("inline-flex items-center hyphens-auto", lineClamp.description)}
source={entry?.description}
target={translation?.description}
/>
)}
{!!isInCollection && <StarIcon className="absolute right-0 top-0" />}
</div>
{!simple && (
<div
className={cn(
"absolute left-4 top-4 z-[1] flex items-center justify-center text-2xl",
randomStyle.card.icon,
"text-[13px]",
"text-text-secondary",
isRead && dimRead && "text-text-tertiary",
)}
>
{icon}
</div>
)}
{/* Common views */}
{(view === FeedViewType.Articles ||
view === FeedViewType.Notifications ||
view === FeedViewType.SocialMedia ||
view === FeedViewType.Audios) && (
<>
{mediaCover ? (
<Media
src={mediaCover.url}
type={mediaCover.type}
previewImageUrl={mediaCover.preview_image_url}
className="min-h-[6em] w-full overflow-hidden"
mediaContainerClassName="size-full min-h-[6em] object-cover"
videoClassName="size-full min-h-[6em] object-cover"
loading="lazy"
proxy={{
width: 600,
height: 0,
}}
blurhash={mediaCover.blurhash || undefined}
style={{
aspectRatio: aspectRatio ?? 1,
}}
/>
) : (
<div className="flex min-h-[6em] flex-col items-center justify-center overflow-hidden px-5 py-16 text-xl font-medium leading-snug text-black/80">
<div className="line-clamp-6 max-w-full break-words">{titleWithKeyword}</div>
</div>
)}
</>
)}
{/* Pictures */}
{view === FeedViewType.Pictures && (
<div className="relative flex gap-2 overflow-x-auto">
{entryMedia ? (
<SwipeMedia
media={entryMedia}
className={cn(
"aspect-square",
"w-full shrink-0 rounded-md [&_img]:rounded-md",
isActive && "rounded-b-none",
)}
imgClassName="object-cover"
onPreview={previewMedia}
/>
) : (
<div className="flex min-h-[6em] flex-col items-center justify-center overflow-hidden px-4 py-20 text-[1.5rem] font-normal leading-[1.2]">
<div className="line-clamp-6 max-w-full break-words">{titleWithKeyword}</div>
</div>
)}
</div>
)}
{/* Videos */}
{view === FeedViewType.Videos && (
<div className="cursor-card w-full">
<div className="relative overflow-x-auto">
{mediaCover ? (
<Media
key={mediaCover.url}
src={mediaCover.url}
type={mediaCover.type}
previewImageUrl={mediaCover.preview_image_url}
className="min-h-[6em] w-full overflow-hidden"
mediaContainerClassName="size-full min-h-[6em] object-cover"
videoClassName="size-full min-h-[6em] object-cover"
loading="lazy"
proxy={{
width: mediaCover.width ?? 640,
height: mediaCover.height ?? 360,
}}
blurhash={mediaCover.blurhash || undefined}
style={{
aspectRatio: aspectRatio ?? 16 / 9,
}}
showFallback={true}
/>
) : (
<div className="flex min-h-[6em] flex-col items-center justify-center overflow-hidden px-4 py-20 text-[1.5rem] font-normal leading-[1.2]">
<div className="line-clamp-6 max-w-full break-words">{titleWithKeyword}</div>
</div>
)}
{miniIframeSrc && showPreview && (
<div className="pointer-events-none absolute inset-0">
<ViewTag
src={miniIframeSrc}
className={cn("pointer-events-none size-full min-h-[6em] object-cover")}
/>
</div>
)}
{!!entry.duration && (
<div className="absolute bottom-2 right-2 rounded-md bg-black/50 px-1 py-0.5 text-xs font-medium text-white">
{entry.duration}
</div>
)}
</div>
<EntryTranslation
className={cn("hyphens-auto", lineClamp.description)}
source={entry?.description}
target={translation?.description}
/>
</div>
)}
</div>
{/* Footer */}
<div className={cn("relative px-1 pb-4 text-sm")}>
<div className="flex items-center">
<div
className={cn(
"bg-accent mr-1 size-1.5 shrink-0 self-center rounded-full duration-200",
asRead && "mr-0 w-0",
)}
/>
<div className={cn("relative mb-1 mt-1.5 flex w-full items-center gap-1 font-medium")}>
<EntryTranslation
source={entry.title}
target={translation?.title}
className="line-clamp-2"
inline={false}
/>
<div className="text-text-secondary ml-4 shrink-0 text-xs">
{!!displayTime && <RelativeTime date={displayTime} />}
</div>
</div>
)
}
{isInCollection && (
<div className="h-0 shrink-0 -translate-y-2">
<StarIcon />
</div>
)}
</div>
AllItem.wrapperClassName = "pl-7 pr-5"
export function AllItemStateLess({ entry, feed }: EntryItemStatelessProps) {
return (
<div className="cursor-menu group relative flex py-4">
<FeedIcon feed={feed} fallback className="mr-2 size-5" />
<div className="-mt-0.5 min-w-0 flex-1 text-sm leading-tight">
<div className="text-text-secondary flex gap-1 text-[10px] font-bold">
<FeedTitle feed={feed} />
<span>·</span>
<span>{!!entry.publishedAt && <RelativeTime date={entry.publishedAt} />}</span>
</div>
<div className="flex min-w-0 items-center justify-between text-[13px]">
<div className="flex min-w-0 items-center gap-1">
<FeedIcon
fallback
noMargin
className="flex"
feed={feeds!}
entry={entry.iconEntry}
size={18}
/>
<span className={cn("text-text-secondary min-w-0 truncate pl-1")}>
<FeedTitle feed={feeds} />
</span>
</div>
<span className={"text-text-secondary ml-2 min-w-0 shrink-0"}>
{dayjs.duration(dayjs(entry.publishedAt).diff(dayjs(), "minute"), "minute").humanize()}
{t("space")}
{t("words.ago")}
</span>
<div className="text-text relative my-0.5 truncate break-words font-medium">
{entry.title}
</div>
</div>
</div>
)
}
AllItem.wrapperClassName = "hover:bg-transparent"
// function AllArticleItem({ entryId, entryPreview, translation }: UniversalItemProps) {
// return <ListItem entryId={entryId} entryPreview={entryPreview} translation={translation} />
// }
// 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 = (
<div className={`relative w-full select-none ${readableContentMaxWidth}`}>
<div className="group relative flex py-4">
<Skeleton className="mr-2 size-5 shrink-0 overflow-hidden rounded-sm" />
<div className="-mt-0.5 line-clamp-4 flex-1 text-sm leading-tight">
<div className="text-material-opaque flex gap-1 text-[10px] font-bold">
<Skeleton className="h-3 w-32 truncate" />
<span>·</span>
<Skeleton className="h-3 w-12 shrink-0" />
</div>
<div className="relative my-0.5 break-words">
<Skeleton className="h-4 w-full" />
<Skeleton className="mt-2 h-4 w-3/4" />
</div>
</div>
</div>
</div>
)

View File

@ -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<AllMasonryProps> = ({
data,
hasNextPage,
endReached,
Footer,
refetch,
}) => {
const scrollElement = useScrollViewElement()
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null)
const [width, setWidth] = useState<number>(0)
const [isLoadingMore, setIsLoadingMore] = useState(false)
const prevDataLengthRef = useRef(data.length)
// Convert entry IDs to masonry items with stable references
const items = useMemo<MasonryItem[]>(
() => 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<HTMLElement>(`[data-index="${index}"]`)
if (byIndex) return byIndex
const id = dataRef.current[index]
if (!id) return null
return containerRef?.querySelector<HTMLElement>(`[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 (
<div ref={setContainerRef} className="mx-4 pt-4">
<LoadingSkeleton />
</div>
)
}
return (
<div ref={setContainerRef} className="mx-4 pb-8 pt-4">
<MasonryWrapper
key={masonryKey}
items={items}
columnGutter={GUTTER}
columnWidth={columnWidth}
columnCount={columnCount}
overscanBy={OVERSCAN}
render={MasonryItemRender}
onRender={handleRender}
itemKey={itemKey}
itemHeightEstimate={200}
onError={() => {
setForceRemountCounter((prev) => prev + 1)
}}
/>
<div className="mt-8">
{Footer && <div className="mb-4">{typeof Footer === "function" ? <Footer /> : Footer}</div>}
{(hasNextPage || isLoadingMore) && <SkeletonGrid columnCount={columnCount} />}
</div>
<EntryColumnShortcutHandler
refetch={refetch}
data={dataRef.current}
handleScrollTo={handleScrollTo}
/>
</div>
)
}
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<RenderComponentProps<MasonryItem>> = ({
data,
index,
}) => {
if (!data || !data.entryId) return <LoadingSkeleton count={1} />
const entry = getEntry(data.entryId)
if (!entry) return <LoadingSkeleton count={1} />
return (
<div
data-entry-id={data.entryId}
data-index={index}
className={clsx("bg-background rounded-lg")}
>
<EntryItem entryId={data.entryId} view={FeedViewType.All} />
</div>
)
}
const MasonryWrapper: FC<{
items: MasonryItem[]
columnGutter: number
columnWidth: number
columnCount: number
overscanBy: number
render: React.ComponentType<RenderComponentProps<MasonryItem>>
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 (
<ErrorBoundary
key={errorKey}
fallback={(errorData) => {
console.error("Masonry error caught:", errorData.error)
setHasError(true)
props.onError?.()
return (
<div className="flex items-center justify-center py-8">
<LoadingSkeleton count={6} />
</div>
)
}}
beforeCapture={() => {
setErrorKey((prev) => prev + 1)
}}
>
{!hasError && (
<Masonry
items={props.items}
columnGutter={props.columnGutter}
columnWidth={props.columnWidth}
columnCount={props.columnCount}
overscanBy={props.overscanBy}
render={props.render}
onRender={props.onRender}
itemKey={props.itemKey}
itemHeightEstimate={props.itemHeightEstimate}
/>
)}
</ErrorBoundary>
)
}
// Loading skeleton component
const LoadingSkeleton: FC<{ count?: number }> = ({ count = 1 }) => {
const keys = useMemo(() => Array.from({ length: count }), [count])
return (
<>
{keys.map((_, index) => (
<div
// eslint-disable-next-line @eslint-react/no-array-index-key
key={index}
className="border-material-ultra-thick bg-background overflow-hidden rounded-lg border"
>
<div className="space-y-3 p-4">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-1/2" />
<Skeleton className="h-32 w-full" />
</div>
</div>
))}
</>
)
}
const SkeletonGrid: FC<{ columnCount: number }> = ({ columnCount }) => {
const keys = useMemo(() => Array.from({ length: columnCount * 2 }, () => null), [columnCount])
return (
<div className="mb-4 grid gap-4" style={{ gridTemplateColumns: `repeat(${columnCount}, 1fr)` }}>
{keys.map((_, index) => (
<LoadingSkeleton count={1} key={index} />
))}
</div>
)
}

View File

@ -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 (
<div className="mt-4 flex gap-[8px] overflow-x-auto pb-2">
{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 (
<Media
style={style}
key={media.url}
src={media.url}
type={media.type}
previewImageUrl={media.preview_image_url}
blurhash={media.blurhash}
className="data-[state=loading]:!bg-material-ultra-thick size-28 shrink-0 cursor-zoom-in"
loading="lazy"
proxy={proxySize}
onClick={(e) => {
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,
)
}}
/>
)
})}
</div>
)
}
// all media has different ratio, use grid layout
return (
<div className="mt-4">
<div
className={cn(
"grid gap-2",
media.length === 2 && "grid-cols-2",
media.length === 3 && "grid-cols-2",
media.length === 4 && "grid-cols-2",
media.length >= 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 (
<Media
style={style}
key={m.url}
src={m.url}
type={m.type}
previewImageUrl={m.preview_image_url}
blurhash={m.blurhash}
className="aspect-square w-full cursor-zoom-in rounded object-cover"
loading="lazy"
proxy={proxySize}
onClick={(e) => {
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,
)
}}
/>
)
})}
</div>
</div>
)
}

View File

@ -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 && <StarIcon className="absolute right-0 top-0" />}
</div>
</div>
<SocialMediaGallery entryId={entryId} />
<MediaGallery entryId={entryId} />
</div>
</div>
)
@ -215,150 +213,6 @@ export const SocialMediaItemSkeleton = (
</div>
)
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 (
<div className="mt-4 flex gap-[8px] overflow-x-auto pb-2">
{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 (
<Media
style={style}
key={media.url}
src={media.url}
type={media.type}
previewImageUrl={media.preview_image_url}
blurhash={media.blurhash}
className="data-[state=loading]:!bg-material-ultra-thick size-28 shrink-0 cursor-zoom-in"
loading="lazy"
proxy={proxySize}
onClick={(e) => {
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,
)
}}
/>
)
})}
</div>
)
}
// all media has different ratio, use grid layout
return (
<div className="mt-4">
<div
className={cn(
"grid gap-2",
media.length === 2 && "grid-cols-2",
media.length === 3 && "grid-cols-2",
media.length === 4 && "grid-cols-2",
media.length >= 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 (
<Media
style={style}
key={m.url}
src={m.url}
type={m.type}
previewImageUrl={m.preview_image_url}
blurhash={m.blurhash}
className="aspect-square w-full cursor-zoom-in rounded object-cover"
loading="lazy"
proxy={proxySize}
onClick={(e) => {
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,
)
}}
/>
)
})}
</div>
</div>
)
}
const collapsedHeight = 300
const collapsedItemCache = new LRUCache<string, boolean>(100)
const CollapsedSocialMediaItem: Component<{

View File

@ -0,0 +1,3 @@
import { atom } from "jotai"
export const socialMediaContentWidthAtom = atom(0)

View File

@ -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<EntryListProps> = (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 (
<AllMasonry
key={feedId}
hasNextPage={hasNextPage}
endReached={fetchNextPage}
data={entriesIds}
Footer={props.Footer}
refetch={refetch}
/>
)
}
if (masonry && view === FeedViewType.Pictures) {
return (
<PictureMasonry

View File

@ -1,4 +1,4 @@
import { views } from "@follow/constants"
import { FeedViewType, views } from "@follow/constants"
import { useCollectionEntryList } from "@follow/store/collection/hooks"
import {
useEntriesQuery,
@ -50,6 +50,7 @@ const useRemoteEntries = (): UseEntriesReturn => {
...(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) {

View File

@ -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,

View File

@ -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",

View File

@ -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<SocialMediaLayoutProps> = ({
/>
{/* Media gallery */}
{!noMedia && <SocialMediaGallery entryId={entryId} />}
{!noMedia && <MediaGallery entryId={entryId} />}
</div>
)
}

View File

@ -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,
)}
>
<span
@ -246,13 +245,13 @@ export function FeedIcon({
<PlatformIcon url={iconProps.platformUrl!} style={sizeStyle} className={className}>
{fallbackSrc ? (
<FallbackableImage
className={cn(marginClassName, className)}
className={marginClassName}
style={sizeStyle}
fallbackUrl={fallbackSrc}
/>
) : (
<m.img
className={cn(marginClassName, className)}
className={marginClassName}
style={sizeStyle}
{...(disableFadeIn || isIconLoaded ? {} : fadeInVariant)}
/>

View File

@ -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,
},