feat: compact unified view
This commit is contained in:
parent
280bc2a45e
commit
6c1ee7dc30
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
import { atom } from "jotai"
|
||||
|
||||
export const socialMediaContentWidthAtom = atom(0)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue