mirror of
https://github.com/RSSNext/Folo.git
synced 2026-10-16 20:51:43 +08:00
refactor: vlist and keep scroll position (#1931)
* init Signed-off-by: Innei <tukon479@gmail.com> * feat: grid Signed-off-by: Innei <tukon479@gmail.com> * refactor: mark read logci Signed-off-by: Innei <tukon479@gmail.com> * chore: remove react-virtuoso Signed-off-by: Innei <tukon479@gmail.com> * fix: video Signed-off-by: Innei <tukon479@gmail.com> * transition Signed-off-by: Innei <tukon479@gmail.com> * fix: image memo loaded status Signed-off-by: Innei <tukon479@gmail.com> * fix: sort button Signed-off-by: Innei <tukon479@gmail.com> * feat: cjk truncate Signed-off-by: Innei <tukon479@gmail.com> --------- Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -38,6 +38,7 @@
|
||||
"@tanstack/react-query": "5.61.4",
|
||||
"@tanstack/react-query-devtools": "5.61.4",
|
||||
"@tanstack/react-query-persist-client": "5.61.4",
|
||||
"@tanstack/react-virtual": "3.10.9",
|
||||
"@use-gesture/react": "10.3.1",
|
||||
"@welldone-software/why-did-you-render": "8.0.3",
|
||||
"@yornaath/batshit": "0.10.1",
|
||||
@@ -88,7 +89,6 @@
|
||||
"react-router": "7.0.1",
|
||||
"react-selecto": "^1.26.3",
|
||||
"react-shadow": "20.5.0",
|
||||
"react-virtuoso": "4.12.2",
|
||||
"react-zoom-pan-pinch": "^3.6.1",
|
||||
"rehype-infer-description-meta": "2.0.0",
|
||||
"rehype-parse": "9.0.1",
|
||||
|
||||
@@ -22,6 +22,8 @@ type BaseProps = {
|
||||
inline?: boolean
|
||||
fitContent?: boolean
|
||||
}
|
||||
|
||||
const isImageLoadedSet = new Set<string>()
|
||||
export type MediaProps = BaseProps &
|
||||
(
|
||||
| (ImgHTMLAttributes<HTMLImageElement> & {
|
||||
@@ -103,7 +105,12 @@ const MediaImpl: FC<MediaProps> = ({
|
||||
[currentState, previewImageUrl, proxy?.width, proxy?.height],
|
||||
)
|
||||
|
||||
const [mediaLoadState, setMediaLoadState] = useState<"loading" | "loaded">("loading")
|
||||
const [mediaLoadState, setMediaLoadState] = useState<"loading" | "loaded">(() => {
|
||||
if (imgSrc) {
|
||||
return isImageLoadedSet.has(imgSrc) ? "loaded" : "loading"
|
||||
}
|
||||
return "loading"
|
||||
})
|
||||
|
||||
const errorHandle: React.ReactEventHandler<HTMLImageElement> = useEventCallback(() => {
|
||||
switch (currentState) {
|
||||
@@ -158,6 +165,10 @@ const MediaImpl: FC<MediaProps> = ({
|
||||
const handleOnLoad: React.ReactEventHandler<HTMLImageElement> = useEventCallback((e) => {
|
||||
setMediaLoadState("loaded")
|
||||
rest.onLoad?.(e as any)
|
||||
|
||||
if (imgSrc) {
|
||||
isImageLoadedSet.add(imgSrc)
|
||||
}
|
||||
if ("cacheDimensions" in props && props.cacheDimensions && src) {
|
||||
saveImageDimensionsToDb(src, {
|
||||
src,
|
||||
|
||||
@@ -24,8 +24,9 @@ export const EntryColumnMobile = () => {
|
||||
return () => clearTimeout(timer)
|
||||
}, [view])
|
||||
usePreventOverscrollBounce()
|
||||
|
||||
return (
|
||||
<div className="flex h-screen min-w-0 grow">
|
||||
<div className="flex h-screen min-w-0 grow overflow-hidden">
|
||||
<EntryColumn />
|
||||
|
||||
{isStartupTimeline && (
|
||||
|
||||
@@ -18,7 +18,6 @@ import { feedIconSelector } from "~/store/feed/selector"
|
||||
import { useUnreadByView } from "~/store/unread/hooks"
|
||||
|
||||
import { ProfileButton } from "../../user/ProfileButton"
|
||||
import styles from "./mobile.module.css"
|
||||
|
||||
export const MobileFloatBar = ({
|
||||
scrollContainer,
|
||||
@@ -74,22 +73,28 @@ export const MobileFloatBar = ({
|
||||
}, [isScrollDown, animateController])
|
||||
|
||||
return (
|
||||
<m.div
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute inset-x-0 flex h-10 justify-center will-change-transform bottom-safe-offset-8",
|
||||
"pointer-events-none absolute inset-x-0 bottom-0 flex h-36 items-end overflow-hidden pb-safe-offset-6",
|
||||
className,
|
||||
)}
|
||||
transition={{ type: "spring" }}
|
||||
animate={animateController}
|
||||
>
|
||||
<div className={styles["float-bar"]}>
|
||||
<m.div
|
||||
className={clsx(
|
||||
"mx-4 inline-flex h-10 min-w-0 items-center rounded-full border border-neutral-200 bg-background pl-4 pr-2 shadow-sm shadow-zinc-100 dark:border-neutral-800",
|
||||
"[box-shadow:0px_8px_30px_rgba(122,122,122,0.2)] dark:[box-shadow:0px_8px_30px_rgba(122,122,122,0.2)]",
|
||||
"pointer-events-auto",
|
||||
)}
|
||||
transition={{ type: "spring" }}
|
||||
animate={animateController}
|
||||
>
|
||||
<PlayerIcon isScrollDown={isScrollDown} onLogoClick={onLogoClick} />
|
||||
<DividerVertical className="h-3/4 shrink-0" />
|
||||
<ViewTabs onViewChange={onViewChange} />
|
||||
<DividerVertical className="h-3/4 shrink-0" />
|
||||
<ProfileButton />
|
||||
</div>
|
||||
</m.div>
|
||||
</m.div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
.float-bar {
|
||||
@apply min-w-0 mx-4 rounded-full border bg-background dark:border-neutral-800 shadow-sm shadow-zinc-100 border-neutral-200 h-10 inline-flex items-center pl-4 pr-2;
|
||||
|
||||
box-shadow: 0px 8px 30px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .float-bar {
|
||||
box-shadow: 0px 8px 30px rgba(122, 122, 122, 0.2);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { useRefValue } from "@follow/hooks"
|
||||
import type { FC } from "react"
|
||||
import { memo, useLayoutEffect, useState } from "react"
|
||||
import { useHotkeys } from "react-hotkeys-hook"
|
||||
import type { VirtuosoHandle } from "react-virtuoso"
|
||||
|
||||
import { useMainContainerElement } from "~/atoms/dom"
|
||||
import { HotKeyScopeMap } from "~/constants"
|
||||
@@ -13,8 +12,8 @@ import { useRouteEntryId } from "~/hooks/biz/useRouteParams"
|
||||
export const EntryColumnShortcutHandler: FC<{
|
||||
refetch: () => void
|
||||
data: readonly string[]
|
||||
virtuosoRef: React.RefObject<VirtuosoHandle>
|
||||
}> = memo(({ data, refetch, virtuosoRef }) => {
|
||||
handleScrollTo: (index: number) => void
|
||||
}> = memo(({ data, refetch, handleScrollTo }) => {
|
||||
const dataRef = useRefValue(data!)
|
||||
|
||||
useHotkeys(
|
||||
@@ -58,9 +57,7 @@ export const EntryColumnShortcutHandler: FC<{
|
||||
|
||||
const nextIndex = Math.min(currentActiveEntryIndex + 1, data.length - 1)
|
||||
|
||||
virtuosoRef.current?.scrollIntoView?.({
|
||||
index: nextIndex,
|
||||
})
|
||||
handleScrollTo(nextIndex)
|
||||
const nextId = data![nextIndex]
|
||||
|
||||
navigate({
|
||||
@@ -78,9 +75,7 @@ export const EntryColumnShortcutHandler: FC<{
|
||||
const nextIndex =
|
||||
currentActiveEntryIndex === -1 ? data.length - 1 : Math.max(0, currentActiveEntryIndex - 1)
|
||||
|
||||
virtuosoRef.current?.scrollIntoView?.({
|
||||
index: nextIndex,
|
||||
})
|
||||
handleScrollTo(nextIndex)
|
||||
const nextId = data![nextIndex]
|
||||
|
||||
navigate({
|
||||
|
||||
@@ -58,7 +58,7 @@ export function ArticleItemStateLess({ entry, feed }: EntryItemStatelessProps) {
|
||||
}
|
||||
|
||||
export const ArticleItemSkeleton = (
|
||||
<div className="relative h-[120px] rounded-md bg-theme-background text-zinc-700 transition-colors dark:text-neutral-400">
|
||||
<div className="relative h-[120px] rounded-md text-zinc-700 transition-colors dark:text-neutral-400">
|
||||
<div className="relative">
|
||||
<div className="group relative flex py-4 pl-3 pr-2">
|
||||
<Skeleton className="mr-2 size-5 rounded-sm" />
|
||||
|
||||
@@ -33,7 +33,7 @@ export function NotificationItemStateLess({ entry, feed }: EntryItemStatelessPro
|
||||
export const NotificationItemSkeleton = (
|
||||
<div className="relative w-full max-w-lg">
|
||||
<div className="group relative flex py-4 pl-3 pr-2">
|
||||
<Skeleton className="mr-2 size-5 shrink-0 overflow-hidden rounded-sm " />
|
||||
<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="flex gap-1 text-[10px] font-bold text-zinc-400 dark:text-neutral-500">
|
||||
<Skeleton className="h-3 w-32 truncate " />
|
||||
|
||||
@@ -46,7 +46,7 @@ export function PictureItem({ entryId, entryPreview, translation }: UniversalIte
|
||||
media={entry.entries.media}
|
||||
className={cn(
|
||||
"aspect-square",
|
||||
"w-full shrink-0 rounded-md",
|
||||
"w-full shrink-0 rounded-md [&_img]:rounded-md",
|
||||
isActive && "rounded-b-none",
|
||||
)}
|
||||
imgClassName="object-cover"
|
||||
|
||||
@@ -33,7 +33,7 @@ import { getEntry } from "~/store/entry"
|
||||
import { imageActions } from "~/store/image"
|
||||
|
||||
import { getMasonryColumnValue, setMasonryColumnValue, useMasonryColumnValue } from "../atoms"
|
||||
import { batchMarkRead } from "../hooks"
|
||||
import { batchMarkRead } from "../hooks/useEntryMarkReadHandler"
|
||||
import { PictureWaterFallItem } from "./picture-item"
|
||||
|
||||
// grid grid-cols-1 @lg:grid-cols-2 @3xl:grid-cols-3 @6xl:grid-cols-4 @7xl:grid-cols-5 px-4 gap-1.5
|
||||
@@ -262,7 +262,7 @@ const MasonryRender: React.ComponentType<
|
||||
}
|
||||
interface MasonryProps {
|
||||
data: string[]
|
||||
endReached: () => Promise<any>
|
||||
endReached: () => any
|
||||
hasNextPage: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +1,10 @@
|
||||
import { ActionButton } from "@follow/components/ui/button/index.js"
|
||||
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
|
||||
import { FeedViewType } from "@follow/constants"
|
||||
import { stopPropagation } from "@follow/utils/dom"
|
||||
import { cn } from "@follow/utils/utils"
|
||||
import { throttle } from "es-toolkit/compat"
|
||||
import { m } from "framer-motion"
|
||||
import type { FC, PropsWithChildren } from "react"
|
||||
import {
|
||||
cloneElement,
|
||||
memo,
|
||||
useCallback,
|
||||
useId,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { cloneElement, memo, useCallback, useId, useMemo, useRef, useState } from "react"
|
||||
import { Trans } from "react-i18next"
|
||||
import { useDebounceCallback } from "usehooks-ts"
|
||||
|
||||
@@ -27,6 +16,20 @@ import { isListSubscription } from "~/store/subscription"
|
||||
|
||||
import { useMarkAllByRoute } from "../hooks/useMarkAll"
|
||||
|
||||
interface DateItemInnerProps {
|
||||
date: Date
|
||||
startTime: number
|
||||
endTime: number
|
||||
className?: string
|
||||
Wrapper?: FC<PropsWithChildren>
|
||||
isSticky?: boolean
|
||||
}
|
||||
|
||||
type DateItemProps = Pick<DateItemInnerProps, "isSticky"> & {
|
||||
view: FeedViewType
|
||||
date: string
|
||||
className?: string
|
||||
}
|
||||
const useParseDate = (date: string) =>
|
||||
useMemo(() => {
|
||||
const dateObj = new Date(date)
|
||||
@@ -37,57 +40,36 @@ const useParseDate = (date: string) =>
|
||||
}
|
||||
}, [date])
|
||||
|
||||
const useSticky = () => {
|
||||
const $scroller = useScrollViewElement()
|
||||
const itemRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [isSticky, setIsSticky] = useState(false)
|
||||
useLayoutEffect(() => {
|
||||
const $ = itemRef.current?.parentElement
|
||||
if (!$) return
|
||||
const handler = throttle((e: HTMLElementEventMap["scroll"]) => {
|
||||
if ((e.target as HTMLElement).scrollTop < 10) {
|
||||
setIsSticky(false)
|
||||
return
|
||||
}
|
||||
const isSticky = $.offsetTop <= 0
|
||||
|
||||
setIsSticky(isSticky)
|
||||
}, 16)
|
||||
$scroller?.addEventListener("scroll", handler)
|
||||
return () => {
|
||||
$scroller?.removeEventListener("scroll", handler)
|
||||
}
|
||||
}, [$scroller])
|
||||
|
||||
return { isSticky, itemRef }
|
||||
}
|
||||
export const DateItem = memo(({ date, view }: { date: string; view: FeedViewType }) => {
|
||||
const className = cn(
|
||||
"lg:pt-2",
|
||||
`relative -mx-2 flex items-center gap-1 bg-background px-4 text-base font-bold text-zinc-800 dark:text-neutral-400`,
|
||||
)
|
||||
export const DateItem = memo(({ date, view, isSticky }: DateItemProps) => {
|
||||
const className = tw`relative flex items-center text-sm lg:text-base gap-1 bg-background px-4 font-bold text-zinc-800 dark:text-neutral-400 h-7`
|
||||
|
||||
if (view === FeedViewType.SocialMedia) {
|
||||
return <SocialMediaDateItem date={date} className={className} />
|
||||
}
|
||||
return <UniversalDateItem date={date} className={className} />
|
||||
return <UniversalDateItem date={date} className={className} isSticky={isSticky} />
|
||||
})
|
||||
const UniversalDateItem = ({ date, className }: { date: string; className?: string }) => {
|
||||
const UniversalDateItem = ({ date, className, isSticky }: Omit<DateItemProps, "view">) => {
|
||||
const { startOfDay, endOfDay, dateObj } = useParseDate(date)
|
||||
|
||||
return (
|
||||
<DateItemInner className={className} date={dateObj} startTime={startOfDay} endTime={endOfDay} />
|
||||
<DateItemInner
|
||||
className={className}
|
||||
date={dateObj}
|
||||
startTime={startOfDay}
|
||||
endTime={endOfDay}
|
||||
isSticky={isSticky}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const DateItemInner: FC<{
|
||||
date: Date
|
||||
startTime: number
|
||||
endTime: number
|
||||
className?: string
|
||||
Wrapper?: FC<PropsWithChildren>
|
||||
}> = ({ date, endTime, startTime, className, Wrapper }) => {
|
||||
const DateItemInner: FC<DateItemInnerProps> = ({
|
||||
date,
|
||||
endTime,
|
||||
startTime,
|
||||
className,
|
||||
Wrapper,
|
||||
isSticky,
|
||||
}) => {
|
||||
const rid = useId()
|
||||
const RelativeElement = useMemo(
|
||||
() => (
|
||||
@@ -102,7 +84,6 @@ const DateItemInner: FC<{
|
||||
startTime,
|
||||
endTime,
|
||||
})
|
||||
const { isSticky, itemRef } = useSticky()
|
||||
|
||||
const [confirmMark, setConfirmMark] = useState(false)
|
||||
const removeConfirm = useDebounceCallback(
|
||||
@@ -125,7 +106,6 @@ const DateItemInner: FC<{
|
||||
return (
|
||||
<div
|
||||
className={cn(className, isSticky && "border-b")}
|
||||
ref={itemRef}
|
||||
onClick={stopPropagation}
|
||||
onMouseEnter={removeConfirm.cancel}
|
||||
onMouseLeave={removeConfirm}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
import { useMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
|
||||
import { FeedViewType } from "@follow/constants"
|
||||
import { useTypeScriptHappyCallback } from "@follow/hooks"
|
||||
import { LRUCache } from "@follow/utils/lru-cache"
|
||||
import type { Range, VirtualItem, Virtualizer } from "@tanstack/react-virtual"
|
||||
import { useVirtualizer } from "@tanstack/react-virtual"
|
||||
import type { FC } from "react"
|
||||
import { Fragment, startTransition, useEffect, useMemo, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
import { setUISetting, useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { getEntry } from "~/store/entry"
|
||||
|
||||
import { EntryItem, EntryItemSkeleton } from "./item"
|
||||
import { PictureMasonry } from "./Items/picture-masonry"
|
||||
import type { EntryListProps } from "./list"
|
||||
|
||||
export const EntryColumnGrid: FC<EntryListProps> = (props) => {
|
||||
const { entriesIds, feedId, hasNextPage, view, fetchNextPage } = props
|
||||
|
||||
const isMobile = useMobile()
|
||||
const masonry = useUISettingKey("pictureViewMasonry") || isMobile
|
||||
|
||||
const filterNoImage = useUISettingKey("pictureViewFilterNoImage")
|
||||
const nextData = useMemo(() => {
|
||||
if (filterNoImage) {
|
||||
return entriesIds.filter((entryId) => {
|
||||
const entry = getEntry(entryId)
|
||||
return entry?.entries.media?.length && entry.entries.media.length > 0
|
||||
})
|
||||
}
|
||||
return entriesIds
|
||||
}, [entriesIds, filterNoImage])
|
||||
const { t } = useTranslation()
|
||||
if (nextData.length === 0 && entriesIds.length > 0) {
|
||||
return (
|
||||
<div className="center absolute inset-x-0 inset-y-12 -translate-y-12 flex-col gap-5">
|
||||
<i className="i-mgc-photo-album-cute-fi size-12" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("entry_column.filtered_content_tip")}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setUISetting("pictureViewFilterNoImage", false)
|
||||
}}
|
||||
>
|
||||
Show All
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasFilteredContent = nextData.length < entriesIds.length
|
||||
|
||||
const FilteredContentTip = hasFilteredContent && !hasNextPage && (
|
||||
<div className="center mb-6 flex flex-col gap-5">
|
||||
<i className="i-mgc-photo-album-cute-fi size-12" />
|
||||
<div>{t("entry_column.filtered_content_tip_2")}</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setUISetting("pictureViewFilterNoImage", false)
|
||||
}}
|
||||
>
|
||||
Show All
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (masonry && view === FeedViewType.Pictures) {
|
||||
return (
|
||||
<>
|
||||
<PictureMasonry
|
||||
key={feedId}
|
||||
hasNextPage={hasNextPage}
|
||||
endReached={fetchNextPage}
|
||||
data={entriesIds}
|
||||
/>
|
||||
|
||||
{FilteredContentTip}
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<VirtualGrid {...props} />
|
||||
{FilteredContentTip}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const capacity = 3 * 2
|
||||
const offsetCache = new LRUCache<string, number>(capacity)
|
||||
const measurementsCache = new LRUCache<string, VirtualItem[]>(capacity)
|
||||
|
||||
const ratioMap = {
|
||||
[FeedViewType.Pictures]: 1,
|
||||
// 16:9
|
||||
[FeedViewType.Videos]: 16 / 9,
|
||||
}
|
||||
const VirtualGrid: FC<EntryListProps> = (props) => {
|
||||
const { entriesIds, feedId, onRangeChange, fetchNextPage, view, Footer, hasNextPage, listRef } =
|
||||
props
|
||||
const scrollRef = useScrollViewElement()
|
||||
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
|
||||
const columns = useMemo(() => {
|
||||
if (!scrollRef) return [0]
|
||||
|
||||
const width = containerWidth
|
||||
const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize)
|
||||
let columnCount = 1 // default (grid-cols-1)
|
||||
if (width >= 32 * rem) columnCount = 2 // @lg (32rem)
|
||||
if (width >= 48 * rem) columnCount = 3 // @3xl (48rem)
|
||||
if (width >= 72 * rem) columnCount = 4 // @6xl (72rem)
|
||||
if (width >= 80 * rem) columnCount = 5 // @8xl (80rem)
|
||||
|
||||
return Array.from({ length: columnCount }).fill(width / columnCount) as number[]
|
||||
}, [containerWidth, scrollRef])
|
||||
|
||||
// Calculate rows based on entries
|
||||
const rows = useMemo(() => {
|
||||
const itemsPerRow = columns.length
|
||||
const rowCount = Math.ceil(entriesIds.length / itemsPerRow)
|
||||
return Array.from({ length: rowCount }, (_, index) =>
|
||||
entriesIds.slice(index * itemsPerRow, (index + 1) * itemsPerRow),
|
||||
)
|
||||
}, [entriesIds, columns.length])
|
||||
|
||||
const rowCacheKey = `${feedId}-row`
|
||||
const columnCacheKey = `${feedId}-column`
|
||||
|
||||
const columnVirtualizer = useVirtualizer({
|
||||
horizontal: true,
|
||||
count: columns.length,
|
||||
getScrollElement: () => scrollRef,
|
||||
estimateSize: (i) => columns[i],
|
||||
overscan: 5,
|
||||
initialOffset: offsetCache.get(columnCacheKey) ?? 0,
|
||||
initialMeasurementsCache: measurementsCache.get(columnCacheKey) ?? [],
|
||||
onChange: useTypeScriptHappyCallback(
|
||||
(virtualizer: Virtualizer<HTMLElement, Element>) => {
|
||||
if (!virtualizer.isScrolling) {
|
||||
measurementsCache.put(columnCacheKey, virtualizer.measurementsCache)
|
||||
offsetCache.put(columnCacheKey, virtualizer.scrollOffset ?? 0)
|
||||
}
|
||||
},
|
||||
[columnCacheKey],
|
||||
),
|
||||
})
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length + 1,
|
||||
estimateSize: () => {
|
||||
return columns[0] / ratioMap[view] + 58
|
||||
},
|
||||
overscan: 5,
|
||||
gap: 8,
|
||||
getScrollElement: () => scrollRef,
|
||||
initialOffset: offsetCache.get(rowCacheKey) ?? 0,
|
||||
initialMeasurementsCache: measurementsCache.get(rowCacheKey) ?? [],
|
||||
paddingEnd: 32,
|
||||
onChange: useTypeScriptHappyCallback(
|
||||
(virtualizer: Virtualizer<HTMLElement, Element>) => {
|
||||
if (!virtualizer.isScrolling) {
|
||||
measurementsCache.put(rowCacheKey, virtualizer.measurementsCache)
|
||||
offsetCache.put(rowCacheKey, virtualizer.scrollOffset ?? 0)
|
||||
}
|
||||
|
||||
if (!virtualizer.range) return
|
||||
|
||||
const columnCount = columns.length
|
||||
const realRange = {
|
||||
startIndex: virtualizer.range.startIndex * columnCount,
|
||||
endIndex: virtualizer.range.endIndex * columnCount,
|
||||
}
|
||||
|
||||
onRangeChange?.(realRange as Range)
|
||||
},
|
||||
[rowCacheKey, columns.length],
|
||||
),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!listRef) return
|
||||
listRef.current = rowVirtualizer
|
||||
}, [rowVirtualizer, listRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!scrollRef) return
|
||||
const handler = () => {
|
||||
const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize)
|
||||
const width = scrollRef.clientWidth - 2 * rem
|
||||
setContainerWidth(width)
|
||||
|
||||
rowVirtualizer.measure()
|
||||
columnVirtualizer.measure()
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(handler)
|
||||
observer.observe(scrollRef)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [columnVirtualizer, rowVirtualizer, scrollRef])
|
||||
|
||||
const virtualItems = rowVirtualizer.getVirtualItems()
|
||||
useEffect(() => {
|
||||
const lastItem = virtualItems.at(-1)
|
||||
|
||||
if (!lastItem) {
|
||||
return
|
||||
}
|
||||
|
||||
const isPlaceholderRow = lastItem.index === rows.length
|
||||
|
||||
if (isPlaceholderRow && hasNextPage) {
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [fetchNextPage, hasNextPage, rows.length, virtualItems])
|
||||
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
setReady(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative mx-4"
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: `${columnVirtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const isLoaderRow = virtualRow.key === rows.length
|
||||
if (isLoaderRow && ready) {
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
className="absolute left-0 top-0 w-full"
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{Footer ? typeof Footer === "function" ? <Footer /> : Footer : null}
|
||||
{hasNextPage && <EntryItemSkeleton view={view} count={6} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Fragment key={virtualRow.key}>
|
||||
{columnVirtualizer.getVirtualItems().map((virtualColumn) => (
|
||||
<div
|
||||
ref={columnVirtualizer.measureElement}
|
||||
key={virtualColumn.index}
|
||||
className="absolute left-0 top-0"
|
||||
style={{
|
||||
height: `${virtualRow.size}px`,
|
||||
width: `${virtualColumn.size}px`,
|
||||
|
||||
transform: `translateX(${virtualColumn.start}px) translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
{ready && (
|
||||
<EntryItem
|
||||
entryId={entriesIds[virtualRow.index * columns.length + virtualColumn.index]}
|
||||
view={view}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+7
-78
@@ -1,11 +1,9 @@
|
||||
import { views } from "@follow/constants"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import type { ListRange } from "react-virtuoso"
|
||||
import { useDebounceCallback } from "usehooks-ts"
|
||||
|
||||
import { useGeneralSettingKey } from "~/atoms/settings/general"
|
||||
import { useRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { useRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { useAuthQuery } from "~/hooks/common"
|
||||
import { apiClient, apiFetch } from "~/lib/api-fetch"
|
||||
import { entries, useEntries } from "~/queries/entries"
|
||||
@@ -13,46 +11,7 @@ import { entryActions, getEntry, useEntryIdsByFeedIdOrView } from "~/store/entry
|
||||
import { useFolderFeedsByFeedId } from "~/store/subscription"
|
||||
import { feedUnreadActions } from "~/store/unread"
|
||||
|
||||
export const useEntryMarkReadHandler = (entriesIds: string[]) => {
|
||||
const renderAsRead = useGeneralSettingKey("renderMarkUnread")
|
||||
const scrollMarkUnread = useGeneralSettingKey("scrollMarkUnread")
|
||||
const feedView = useRouteParamsSelector((params) => params.view)
|
||||
|
||||
const handleMarkReadInRange = useDebounceCallback(
|
||||
async ({ startIndex }: ListRange) => {
|
||||
const idSlice = entriesIds?.slice(0, startIndex)
|
||||
|
||||
if (!idSlice) return
|
||||
batchMarkRead(idSlice)
|
||||
},
|
||||
500,
|
||||
{ leading: false },
|
||||
)
|
||||
|
||||
const handleRenderAsRead = useCallback(
|
||||
async ({ startIndex, endIndex }: ListRange, enabled?: boolean) => {
|
||||
if (!enabled) return
|
||||
const idSlice = entriesIds?.slice(startIndex, endIndex)
|
||||
|
||||
if (!idSlice) return
|
||||
batchMarkRead(idSlice)
|
||||
},
|
||||
[entriesIds],
|
||||
)
|
||||
|
||||
return useMemo(() => {
|
||||
if (views[feedView].wideMode && renderAsRead) {
|
||||
return handleRenderAsRead
|
||||
}
|
||||
|
||||
if (scrollMarkUnread) {
|
||||
return handleMarkReadInRange
|
||||
}
|
||||
return
|
||||
}, [feedView, handleMarkReadInRange, handleRenderAsRead, renderAsRead, scrollMarkUnread])
|
||||
}
|
||||
const anyString = [] as string[]
|
||||
|
||||
export const useEntriesByView = ({
|
||||
onReset,
|
||||
isArchived,
|
||||
@@ -144,20 +103,11 @@ export const useEntriesByView = ({
|
||||
}
|
||||
if (!isFetchingFirstPage) {
|
||||
prevEntryIdsRef.current = entryIds
|
||||
setMergedEntries({ ...mergedEntries, [view]: entryIds })
|
||||
|
||||
onReset?.()
|
||||
}
|
||||
}, [isFetchingFirstPage, isArchived])
|
||||
|
||||
const [mergedEntries, setMergedEntries] = useState<Record<number, string[]>>({
|
||||
0: [],
|
||||
1: [],
|
||||
2: [],
|
||||
3: [],
|
||||
4: [],
|
||||
5: [],
|
||||
})
|
||||
|
||||
const entryIdsAsDeps = entryIds.toString()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -166,23 +116,22 @@ export const useEntriesByView = ({
|
||||
useEffect(() => {
|
||||
if (!prevEntryIdsRef.current) {
|
||||
prevEntryIdsRef.current = entryIds
|
||||
setMergedEntries({ ...mergedEntries, [view]: entryIds })
|
||||
|
||||
return
|
||||
}
|
||||
// merge the new entries with the old entries, and unique them
|
||||
const nextIds = [...new Set([...prevEntryIdsRef.current, ...entryIds])]
|
||||
prevEntryIdsRef.current = nextIds
|
||||
setMergedEntries({ ...mergedEntries, [view]: entryIds })
|
||||
}, [entryIdsAsDeps])
|
||||
|
||||
const sortEntries = useMemo(
|
||||
() =>
|
||||
isCollection
|
||||
? sortEntriesIdByStarAt(mergedEntries[view])
|
||||
? sortEntriesIdByStarAt(entryIds)
|
||||
: listId
|
||||
? sortEntriesIdByEntryInsertedAt(mergedEntries[view])
|
||||
: sortEntriesIdByEntryPublishedAt(mergedEntries[view]),
|
||||
[isCollection, listId, mergedEntries, view],
|
||||
? sortEntriesIdByEntryInsertedAt(entryIds)
|
||||
: sortEntriesIdByEntryPublishedAt(entryIds),
|
||||
[entryIds, isCollection, listId],
|
||||
)
|
||||
|
||||
const groupByDate = useGeneralSettingKey("groupByDate")
|
||||
@@ -230,26 +179,6 @@ export const useEntriesByView = ({
|
||||
}
|
||||
}
|
||||
|
||||
export function batchMarkRead(ids: string[]) {
|
||||
const batchLikeIds = [] as [string, string][]
|
||||
const entriesId2Map = entryActions.getFlattenMapEntries()
|
||||
for (const id of ids) {
|
||||
const entry = entriesId2Map[id]
|
||||
|
||||
if (!entry) continue
|
||||
const isRead = entry.read
|
||||
if (!isRead) {
|
||||
batchLikeIds.push([entry.feedId, id])
|
||||
}
|
||||
}
|
||||
|
||||
if (batchLikeIds.length > 0) {
|
||||
for (const [feedId, id] of batchLikeIds) {
|
||||
entryActions.markRead({ feedId, entryId: id, read: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sortEntriesIdByEntryPublishedAt(entries: string[]) {
|
||||
const entriesId2Map = entryActions.getFlattenMapEntries()
|
||||
return entries
|
||||
@@ -0,0 +1,55 @@
|
||||
import { views } from "@follow/constants"
|
||||
import type { Range } from "@tanstack/react-virtual"
|
||||
import { useMemo } from "react"
|
||||
import { useDebounceCallback } from "usehooks-ts"
|
||||
|
||||
import { useGeneralSettingKey } from "~/atoms/settings/general"
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { entryActions } from "~/store/entry"
|
||||
|
||||
export const useEntryMarkReadHandler = (entriesIds: string[]) => {
|
||||
const renderAsRead = useGeneralSettingKey("renderMarkUnread")
|
||||
const scrollMarkUnread = useGeneralSettingKey("scrollMarkUnread")
|
||||
const feedView = useRouteParamsSelector((params) => params.view)
|
||||
|
||||
const handleRenderAsRead = useDebounceCallback(
|
||||
({ startIndex, endIndex }: Range, enabled?: boolean) => {
|
||||
if (!enabled) return
|
||||
const idSlice = entriesIds?.slice(startIndex, endIndex)
|
||||
|
||||
if (!idSlice) return
|
||||
batchMarkRead(idSlice)
|
||||
},
|
||||
)
|
||||
|
||||
return useMemo(() => {
|
||||
if (views[feedView].wideMode && renderAsRead) {
|
||||
return handleRenderAsRead
|
||||
}
|
||||
|
||||
if (scrollMarkUnread) {
|
||||
return handleRenderAsRead
|
||||
}
|
||||
return
|
||||
}, [feedView, handleRenderAsRead, renderAsRead, scrollMarkUnread])
|
||||
}
|
||||
|
||||
export function batchMarkRead(ids: string[]) {
|
||||
const batchLikeIds = [] as [string, string][]
|
||||
const entriesId2Map = entryActions.getFlattenMapEntries()
|
||||
for (const id of ids) {
|
||||
const entry = entriesId2Map[id]
|
||||
|
||||
if (!entry) continue
|
||||
const isRead = entry.read
|
||||
if (!isRead) {
|
||||
batchLikeIds.push([entry.feedId, id])
|
||||
}
|
||||
}
|
||||
|
||||
if (batchLikeIds.length > 0) {
|
||||
for (const [feedId, id] of batchLikeIds) {
|
||||
entryActions.markRead({ feedId, entryId: id, read: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,63 +1,40 @@
|
||||
import { useMobile } from "@follow/components/hooks/useMobile.js"
|
||||
import { Button } from "@follow/components/ui/button/index.js"
|
||||
import { FeedViewType, views } from "@follow/constants"
|
||||
import { useTitle, useTypeScriptHappyCallback } from "@follow/hooks"
|
||||
import { views } from "@follow/constants"
|
||||
import { useTitle } from "@follow/hooks"
|
||||
import type { FeedModel } from "@follow/models/types"
|
||||
import { isBizId } from "@follow/utils/utils"
|
||||
import type { FunctionComponent } from "react"
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import type { Range, Virtualizer } from "@tanstack/react-virtual"
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import type {
|
||||
ScrollSeekConfiguration,
|
||||
VirtuosoGridProps,
|
||||
VirtuosoHandle,
|
||||
VirtuosoProps,
|
||||
} from "react-virtuoso"
|
||||
import { VirtuosoGrid } from "react-virtuoso"
|
||||
|
||||
import { useGeneralSettingKey } from "~/atoms/settings/general"
|
||||
import { setUISetting, useUISettingKey } from "~/atoms/settings/ui"
|
||||
import { FeedFoundCanBeFollowError } from "~/components/errors/FeedFoundCanBeFollowErrorFallback"
|
||||
import { FeedNotFound } from "~/components/errors/FeedNotFound"
|
||||
import { ReactVirtuosoItemPlaceholder } from "~/components/ui/placeholder"
|
||||
import { FEED_COLLECTION_LIST, ROUTE_FEED_PENDING } from "~/constants"
|
||||
import { ENTRY_COLUMN_LIST_SCROLLER_ID } from "~/constants/dom"
|
||||
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
|
||||
import { useRouteParams, useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
import { useFeed } from "~/queries/feed"
|
||||
import { entryActions, getEntry, useEntry } from "~/store/entry"
|
||||
import { entryActions, useEntry } from "~/store/entry"
|
||||
import { useFeedById, useFeedHeaderTitle } from "~/store/feed"
|
||||
import { useSubscriptionByFeedId } from "~/store/subscription"
|
||||
|
||||
import { useEntriesByView, useEntryMarkReadHandler } from "./hooks"
|
||||
import { EntryColumnGrid } from "./grid"
|
||||
import { useEntriesByView } from "./hooks/useEntriesByView"
|
||||
import { useSnapEntryIdList } from "./hooks/useEntryIdListSnap"
|
||||
import { EntryItem, EntryItemSkeleton } from "./item"
|
||||
import { PictureMasonry } from "./Items/picture-masonry"
|
||||
import { useEntryMarkReadHandler } from "./hooks/useEntryMarkReadHandler"
|
||||
import { EntryListHeader } from "./layouts/EntryListHeader"
|
||||
import { EntryEmptyList, EntryList, EntryListContent } from "./lists"
|
||||
import { girdClassNames } from "./styles"
|
||||
import { EntryEmptyList, EntryList } from "./list"
|
||||
import { EntryColumnWrapper } from "./wrapper"
|
||||
|
||||
const scrollSeekConfiguration: ScrollSeekConfiguration = {
|
||||
enter: (velocity) => Math.abs(velocity) > 1000,
|
||||
exit: (velocity) => Math.abs(velocity) < 1000,
|
||||
}
|
||||
|
||||
const prevScrollTopMap = {}
|
||||
|
||||
export type VirtuosoComponentProps = { onlyShowArchivedButton: boolean }
|
||||
type VirtuosoComponentPropsContext = { context?: VirtuosoComponentProps }
|
||||
|
||||
function EntryColumnImpl() {
|
||||
const { t } = useTranslation()
|
||||
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
||||
const [isArchived, setIsArchived] = useState(false)
|
||||
const unreadOnly = useGeneralSettingKey("unreadOnly")
|
||||
const listRef = useRef<Virtualizer<HTMLElement, Element>>()
|
||||
const entries = useEntriesByView({
|
||||
onReset: useCallback(() => {
|
||||
virtuosoRef.current?.scrollTo({
|
||||
top: 0,
|
||||
})
|
||||
listRef.current?.scrollToIndex(0)
|
||||
}, []),
|
||||
isArchived,
|
||||
})
|
||||
@@ -128,79 +105,54 @@ function EntryColumnImpl() {
|
||||
}
|
||||
}, [shouldLoadArchivedEntries])
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!isInteracted.current) {
|
||||
isInteracted.current = true
|
||||
}
|
||||
|
||||
const handleScroll = useCallback(
|
||||
(e: React.UIEvent<HTMLDivElement>) => {
|
||||
if (!isInteracted.current) {
|
||||
isInteracted.current = true
|
||||
}
|
||||
const { scrollTop } = e.currentTarget
|
||||
if (!routeFeedId) return
|
||||
|
||||
if (!routeFeedId) return
|
||||
prevScrollTopMap[routeFeedId] = scrollTop
|
||||
},
|
||||
[routeFeedId],
|
||||
)
|
||||
|
||||
const virtuosoOptions = {
|
||||
components: {
|
||||
List: EntryListContent,
|
||||
Footer: useCallback(
|
||||
({ context }: VirtuosoComponentPropsContext) => {
|
||||
if (!isFetchingNextPage) {
|
||||
if (showArchivedButton) {
|
||||
return (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button variant="outline" onClick={() => setIsArchived(true)}>
|
||||
{t("words.load_archived_entries")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
} else {
|
||||
if (context?.onlyShowArchivedButton) return null
|
||||
return (
|
||||
<EntryItemSkeleton view={view} count={entries.data?.pages?.[0].data?.length || 20} />
|
||||
)
|
||||
}
|
||||
},
|
||||
[isFetchingNextPage, showArchivedButton, t, view, entries.data?.pages],
|
||||
),
|
||||
ScrollSeekPlaceholder: useCallback(() => <EntryItemSkeleton view={view} count={1} />, [view]),
|
||||
},
|
||||
scrollSeekConfiguration,
|
||||
rangeChanged: (...args: any[]) => {
|
||||
handleMarkReadInRange &&
|
||||
// @ts-expect-error
|
||||
handleMarkReadInRange(...args, isInteracted.current)
|
||||
},
|
||||
customScrollParent: scrollRef.current!,
|
||||
initialScrollTop: prevScrollTopMap[routeFeedId || ""] || 0,
|
||||
|
||||
endReached: useCallback(async () => {
|
||||
if (!entries.isFetchingNextPage && entries.hasNextPage) {
|
||||
await entries.fetchNextPage()
|
||||
}
|
||||
}, [entries]),
|
||||
data: entriesIds,
|
||||
onScroll: handleScroll,
|
||||
itemContent: useTypeScriptHappyCallback(
|
||||
(_, entryId) => {
|
||||
if (!entryId) return <ReactVirtuosoItemPlaceholder />
|
||||
|
||||
return <EntryItem key={entryId} entryId={entryId} view={view} />
|
||||
},
|
||||
[view],
|
||||
),
|
||||
} satisfies VirtuosoProps<string, VirtuosoComponentProps>
|
||||
const [first, second] = rangeQueueRef.current
|
||||
if (first && second && second.startIndex - first.startIndex > 0) {
|
||||
handleMarkReadInRange?.(
|
||||
{
|
||||
startIndex: first.startIndex,
|
||||
endIndex: second.startIndex,
|
||||
} as Range,
|
||||
isInteracted.current,
|
||||
)
|
||||
}
|
||||
}, [handleMarkReadInRange, routeFeedId])
|
||||
|
||||
const navigate = useNavigateEntry()
|
||||
const rangeQueueRef = useRef<Range[]>([])
|
||||
const isRefreshing = entries.isFetching && !entries.isFetchingNextPage
|
||||
const renderAsRead = useGeneralSettingKey("renderMarkUnread")
|
||||
const handleRangeChange = useCallback(
|
||||
(e: Range) => {
|
||||
rangeQueueRef.current.push(e)
|
||||
if (rangeQueueRef.current.length > 2) {
|
||||
rangeQueueRef.current.shift()
|
||||
}
|
||||
|
||||
if (!renderAsRead) return
|
||||
if (!views[view].wideMode) {
|
||||
return
|
||||
}
|
||||
// For gird, render as mark read logic
|
||||
handleMarkReadInRange?.(e, isInteracted.current)
|
||||
},
|
||||
[handleMarkReadInRange, renderAsRead, view],
|
||||
)
|
||||
|
||||
const fetchNextPage = useCallback(() => {
|
||||
if (entries.hasNextPage && !entries.isFetchingNextPage) {
|
||||
entries.fetchNextPage()
|
||||
}
|
||||
}, [entries])
|
||||
const isMobile = useMobile()
|
||||
|
||||
const ListComponent = views[view].gridMode ? EntryColumnGrid : EntryList
|
||||
return (
|
||||
<div
|
||||
data-hide-in-print
|
||||
@@ -214,10 +166,9 @@ function EntryColumnImpl() {
|
||||
})
|
||||
}
|
||||
>
|
||||
{virtuosoOptions.data.length === 0 &&
|
||||
!entries.isLoading &&
|
||||
!entries.error &&
|
||||
feed?.type === "feed" && <AddFeedHelper />}
|
||||
{entriesIds.length === 0 && !entries.isLoading && !entries.error && feed?.type === "feed" && (
|
||||
<AddFeedHelper />
|
||||
)}
|
||||
|
||||
<EntryListHeader
|
||||
refetch={entries.refetch}
|
||||
@@ -230,22 +181,30 @@ function EntryColumnImpl() {
|
||||
onPullToRefresh={entries.refetch}
|
||||
key={`${routeFeedId}-${view}`}
|
||||
>
|
||||
{virtuosoOptions.data.length === 0 && !showArchivedButton ? (
|
||||
{entriesIds.length === 0 && !showArchivedButton ? (
|
||||
entries.isLoading ? null : (
|
||||
<EntryEmptyList />
|
||||
)
|
||||
) : view && views[view].gridMode ? (
|
||||
<ListGird
|
||||
virtuosoOptions={virtuosoOptions}
|
||||
virtuosoRef={virtuosoRef}
|
||||
hasNextPage={entries.hasNextPage}
|
||||
/>
|
||||
) : (
|
||||
<EntryList
|
||||
{...virtuosoOptions}
|
||||
virtuosoRef={virtuosoRef}
|
||||
<ListComponent
|
||||
listRef={listRef}
|
||||
onRangeChange={handleRangeChange}
|
||||
hasNextPage={entries.hasNextPage}
|
||||
view={view}
|
||||
feedId={routeFeedId || ""}
|
||||
entriesIds={entriesIds}
|
||||
fetchNextPage={fetchNextPage}
|
||||
refetch={entries.refetch}
|
||||
groupCounts={groupedCounts}
|
||||
Footer={
|
||||
!isFetchingNextPage && showArchivedButton ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Button variant="outline" onClick={() => setIsArchived(true)}>
|
||||
{t("words.load_archived_entries")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</EntryColumnWrapper>
|
||||
@@ -253,105 +212,6 @@ function EntryColumnImpl() {
|
||||
)
|
||||
}
|
||||
|
||||
const ListGird = ({
|
||||
virtuosoOptions,
|
||||
virtuosoRef,
|
||||
hasNextPage,
|
||||
}: {
|
||||
virtuosoOptions: Omit<
|
||||
VirtuosoGridProps<string, VirtuosoComponentProps>,
|
||||
"data" | "endReached"
|
||||
> & {
|
||||
data: string[]
|
||||
endReached: () => Promise<any>
|
||||
}
|
||||
virtuosoRef: React.RefObject<VirtuosoHandle>
|
||||
hasNextPage: boolean
|
||||
}) => {
|
||||
const isMobile = useMobile()
|
||||
const masonry = useUISettingKey("pictureViewMasonry") || isMobile
|
||||
const view = useRouteParamsSelector((s) => s.view)
|
||||
const feedId = useRouteParamsSelector((s) => s.feedId)
|
||||
const filterNoImage = useUISettingKey("pictureViewFilterNoImage")
|
||||
const nextData = useMemo(() => {
|
||||
if (filterNoImage) {
|
||||
return virtuosoOptions.data.filter((entryId) => {
|
||||
const entry = getEntry(entryId)
|
||||
return entry?.entries.media?.length && entry.entries.media.length > 0
|
||||
})
|
||||
}
|
||||
return virtuosoOptions.data
|
||||
}, [virtuosoOptions.data, filterNoImage])
|
||||
const { t } = useTranslation()
|
||||
if (nextData.length === 0 && virtuosoOptions.data.length > 0) {
|
||||
return (
|
||||
<div className="center absolute inset-x-0 inset-y-12 -translate-y-12 flex-col gap-5">
|
||||
<i className="i-mgc-photo-album-cute-fi size-12" />
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("entry_column.filtered_content_tip")}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setUISetting("pictureViewFilterNoImage", false)
|
||||
}}
|
||||
>
|
||||
Show All
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasFilteredContent = nextData.length < virtuosoOptions.data.length
|
||||
|
||||
const FilteredContentTip = hasFilteredContent && !hasNextPage && (
|
||||
<div className="center mb-6 flex flex-col gap-5">
|
||||
<i className="i-mgc-photo-album-cute-fi size-12" />
|
||||
<div>{t("entry_column.filtered_content_tip_2")}</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
setUISetting("pictureViewFilterNoImage", false)
|
||||
}}
|
||||
>
|
||||
Show All
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (masonry && view === FeedViewType.Pictures) {
|
||||
return (
|
||||
<>
|
||||
<PictureMasonry
|
||||
key={feedId}
|
||||
hasNextPage={virtuosoOptions.totalCount! > virtuosoOptions.data.length}
|
||||
endReached={virtuosoOptions.endReached}
|
||||
data={nextData}
|
||||
/>
|
||||
|
||||
{virtuosoOptions.components?.Footer &&
|
||||
(virtuosoOptions.components.Footer as FunctionComponent<VirtuosoComponentPropsContext>)({
|
||||
context: { onlyShowArchivedButton: true },
|
||||
})}
|
||||
|
||||
{FilteredContentTip}
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<VirtuosoGrid
|
||||
id={ENTRY_COLUMN_LIST_SCROLLER_ID}
|
||||
listClassName={girdClassNames}
|
||||
{...virtuosoOptions}
|
||||
data={nextData}
|
||||
ref={virtuosoRef}
|
||||
/>
|
||||
{FilteredContentTip}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const AddFeedHelper = () => {
|
||||
const feedId = useRouteParamsSelector((s) => s.feedId)
|
||||
const feedQuery = useFeed({ id: feedId })
|
||||
|
||||
@@ -126,7 +126,7 @@ export const EntryItemWrapper: FC<
|
||||
className={cn(
|
||||
"relative",
|
||||
asRead ? "text-zinc-700 dark:text-neutral-400" : "text-zinc-900 dark:text-neutral-300",
|
||||
views[view as FeedViewType]?.wideMode ? "rounded-md" : "-mx-2 px-2",
|
||||
views[view as FeedViewType]?.wideMode ? "rounded-md" : "px-2",
|
||||
"duration-200 hover:bg-theme-item-hover",
|
||||
(isActive || isContextMenuOpen) && "!bg-theme-item-active",
|
||||
itemClassName,
|
||||
|
||||
@@ -70,7 +70,7 @@ export const EntryListHeader: FC<EntryListHeaderProps> = ({ refetch, isRefreshin
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"mb-2 flex w-full flex-col pr-4 transition-[padding] duration-300 ease-in-out",
|
||||
"flex w-full flex-col pb-2 pr-4 transition-[padding] duration-300 ease-in-out",
|
||||
"pl-6 pt-safe-offset-2.5",
|
||||
"bg-background",
|
||||
)}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const TimelineTabs = () => {
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
className="-ml-3 -mr-4 mt-2 flex overflow-x-auto overflow-y-hidden scrollbar-none"
|
||||
className="-ml-6 -mr-4 mt-2 flex overflow-x-auto overflow-y-hidden pl-3 scrollbar-none"
|
||||
value={timeline}
|
||||
onValueChange={(val) => {
|
||||
if (!val) {
|
||||
@@ -43,9 +43,9 @@ export const TimelineTabs = () => {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TabsList className="justify-start border-b-0">
|
||||
<TabsList className="justify-start border-b-0 [&_span]:text-xs">
|
||||
<TabsTrigger variant={"rounded"} className="p-0" value="">
|
||||
Yours
|
||||
<span>Yours</span>
|
||||
</TabsTrigger>
|
||||
{listsData.map((s) => (
|
||||
<TabsTrigger variant={"rounded"} className="p-0" key={s.listId} value={s.listId!}>
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { EmptyIcon } from "@follow/components/icons/empty.jsx"
|
||||
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
|
||||
import type { FeedViewType } from "@follow/constants"
|
||||
import { useTypeScriptHappyCallback } from "@follow/hooks"
|
||||
import { LRUCache } from "@follow/utils/lru-cache"
|
||||
import type { Range, VirtualItem, Virtualizer } from "@tanstack/react-virtual"
|
||||
import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual"
|
||||
import clsx from "clsx"
|
||||
import type { HTMLMotionProps } from "framer-motion"
|
||||
import type { FC, MutableRefObject, ReactNode } from "react"
|
||||
import {
|
||||
forwardRef,
|
||||
Fragment,
|
||||
memo,
|
||||
startTransition,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import { useEventCallback } from "usehooks-ts"
|
||||
|
||||
import { useGeneralSettingKey } from "~/atoms/settings/general"
|
||||
import { m } from "~/components/common/Motion"
|
||||
import { useRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { useEntry } from "~/store/entry"
|
||||
import { isListSubscription } from "~/store/subscription"
|
||||
|
||||
import { DateItem } from "./components/DateItem"
|
||||
import { EntryColumnShortcutHandler } from "./EntryColumnShortcutHandler"
|
||||
import { EntryItem, EntryItemSkeleton } from "./item"
|
||||
|
||||
export const EntryListContent = forwardRef<HTMLDivElement>((props, ref) => (
|
||||
<div className="px-2" {...props} ref={ref} />
|
||||
))
|
||||
|
||||
export const EntryEmptyList = forwardRef<HTMLDivElement, HTMLMotionProps<"div">>((props, ref) => {
|
||||
const unreadOnly = useGeneralSettingKey("unreadOnly")
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<m.div
|
||||
className="absolute -mt-6 flex size-full grow flex-col items-center justify-center gap-2 text-zinc-400"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
{unreadOnly ? (
|
||||
<>
|
||||
<i className="i-mgc-celebrate-cute-re -mt-11 text-3xl" />
|
||||
<span className="text-base">{t("entry_list.zero_unread")}</span>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex -translate-y-6 flex-col items-center justify-center gap-2">
|
||||
<EmptyIcon className="size-[30px]" />
|
||||
<span className="text-base">{t("words.zero_items")}</span>
|
||||
</div>
|
||||
)}
|
||||
</m.div>
|
||||
)
|
||||
})
|
||||
|
||||
export type EntryListProps = {
|
||||
feedId: string
|
||||
entriesIds: string[]
|
||||
view: FeedViewType
|
||||
|
||||
hasNextPage: boolean
|
||||
fetchNextPage: () => void
|
||||
refetch: () => void
|
||||
|
||||
groupCounts?: number[]
|
||||
|
||||
Footer?: FC | ReactNode
|
||||
|
||||
onRangeChange?: (range: Range) => void
|
||||
|
||||
listRef?: MutableRefObject<Virtualizer<HTMLElement, Element> | undefined>
|
||||
}
|
||||
|
||||
const capacity = 3
|
||||
const offsetCache = new LRUCache<string, number>(capacity)
|
||||
const measurementsCache = new LRUCache<string, VirtualItem[]>(capacity)
|
||||
export const EntryList: FC<EntryListProps> = memo(
|
||||
({
|
||||
feedId,
|
||||
view,
|
||||
entriesIds,
|
||||
fetchNextPage,
|
||||
refetch,
|
||||
hasNextPage,
|
||||
groupCounts,
|
||||
Footer,
|
||||
listRef,
|
||||
onRangeChange,
|
||||
}) => {
|
||||
// Prevent scroll list move when press up/down key, the up/down key should be taken over by the shortcut key we defined.
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = useCallback((e) => {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const scrollRef = useScrollViewElement()
|
||||
|
||||
const stickyIndexes = useMemo(
|
||||
() =>
|
||||
groupCounts
|
||||
? groupCounts.reduce(
|
||||
(acc, count, index) => {
|
||||
acc[index + 1] = acc[index] + count
|
||||
return acc
|
||||
},
|
||||
[0],
|
||||
)
|
||||
: [],
|
||||
[groupCounts],
|
||||
)
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: entriesIds.length + 1,
|
||||
estimateSize: () => 112,
|
||||
overscan: 5,
|
||||
getScrollElement: () => scrollRef,
|
||||
initialOffset: offsetCache.get(feedId) ?? 0,
|
||||
initialMeasurementsCache: measurementsCache.get(feedId) ?? [],
|
||||
onChange: useTypeScriptHappyCallback(
|
||||
(virtualizer: Virtualizer<HTMLElement, Element>) => {
|
||||
if (!virtualizer.isScrolling) {
|
||||
measurementsCache.put(feedId, virtualizer.measurementsCache)
|
||||
offsetCache.put(feedId, virtualizer.scrollOffset ?? 0)
|
||||
}
|
||||
|
||||
onRangeChange?.(virtualizer.range as Range)
|
||||
},
|
||||
[feedId],
|
||||
),
|
||||
rangeExtractor: useTypeScriptHappyCallback(
|
||||
(range: Range) => {
|
||||
activeStickyIndexRef.current =
|
||||
[...stickyIndexes].reverse().find((index) => range.startIndex >= index) ?? 0
|
||||
|
||||
const next = new Set([activeStickyIndexRef.current, ...defaultRangeExtractor(range)])
|
||||
|
||||
return [...next].sort((a, b) => a - b)
|
||||
},
|
||||
[stickyIndexes],
|
||||
),
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!listRef) return
|
||||
listRef.current = rowVirtualizer
|
||||
}, [rowVirtualizer, listRef])
|
||||
|
||||
const handleScrollTo = useEventCallback((index: number) => {
|
||||
rowVirtualizer.scrollToIndex(index)
|
||||
})
|
||||
|
||||
const activeStickyIndexRef = useRef(0)
|
||||
const isActiveSticky = (index: number) => activeStickyIndexRef.current === index
|
||||
const isSticky = (index: number) => stickyIndexes.includes(index)
|
||||
|
||||
const virtualItems = rowVirtualizer.getVirtualItems()
|
||||
useEffect(() => {
|
||||
const lastItem = virtualItems.at(-1)
|
||||
|
||||
if (!lastItem) {
|
||||
return
|
||||
}
|
||||
|
||||
const isPlaceholderRow = lastItem.index === entriesIds.length
|
||||
|
||||
if (isPlaceholderRow && hasNextPage) {
|
||||
fetchNextPage()
|
||||
}
|
||||
}, [entriesIds.length, fetchNextPage, hasNextPage, virtualItems])
|
||||
|
||||
const [isScrollTop, setIsScrollTop] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const $scrollRef = scrollRef
|
||||
if (!$scrollRef) return
|
||||
const handleScroll = () => {
|
||||
setIsScrollTop($scrollRef.scrollTop <= 0)
|
||||
}
|
||||
$scrollRef.addEventListener("scroll", handleScroll)
|
||||
|
||||
return () => {
|
||||
$scrollRef.removeEventListener("scroll", handleScroll)
|
||||
}
|
||||
}, [scrollRef])
|
||||
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
startTransition(() => {
|
||||
setReady(true)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onKeyDown={handleKeyDown}
|
||||
className={"relative w-full"}
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
if (!ready) return null
|
||||
// Last placeholder row
|
||||
const isLoaderRow = virtualRow.index === entriesIds.length
|
||||
|
||||
const transform = `translateY(${virtualRow.start}px)`
|
||||
if (isLoaderRow) {
|
||||
const Content = hasNextPage ? (
|
||||
<EntryItemSkeleton view={view} count={6} />
|
||||
) : Footer ? (
|
||||
typeof Footer === "function" ? (
|
||||
<Footer />
|
||||
) : (
|
||||
Footer
|
||||
)
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rowVirtualizer.measureElement}
|
||||
className="absolute left-0 top-0 w-full will-change-transform"
|
||||
key={virtualRow.key}
|
||||
style={{
|
||||
transform,
|
||||
}}
|
||||
>
|
||||
{Content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const activeSticky = isActiveSticky(virtualRow.index)
|
||||
const sticky = isSticky(virtualRow.index)
|
||||
return (
|
||||
<Fragment key={virtualRow.key}>
|
||||
{sticky && (
|
||||
<div
|
||||
className={clsx(
|
||||
"bg-background",
|
||||
activeSticky
|
||||
? "sticky top-0 z-[1]"
|
||||
: "absolute left-0 top-0 z-[2] w-full will-change-transform",
|
||||
)}
|
||||
style={
|
||||
!activeSticky
|
||||
? {
|
||||
transform,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<EntryHeadDateItem
|
||||
entryId={entriesIds[virtualRow.index]}
|
||||
isSticky={!isScrollTop && activeSticky}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="absolute left-0 top-0 w-full will-change-transform"
|
||||
style={{
|
||||
transform,
|
||||
paddingTop: sticky ? "1.75rem" : undefined,
|
||||
}}
|
||||
ref={rowVirtualizer.measureElement}
|
||||
data-index={virtualRow.index}
|
||||
>
|
||||
<EntryItem entryId={entriesIds[virtualRow.index]} view={view} />
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<EntryColumnShortcutHandler
|
||||
refetch={refetch}
|
||||
data={entriesIds}
|
||||
handleScrollTo={handleScrollTo}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const EntryHeadDateItem: FC<{
|
||||
entryId: string
|
||||
isSticky?: boolean
|
||||
}> = memo(({ entryId, isSticky }) => {
|
||||
const entry = useEntry(entryId)
|
||||
|
||||
const routeParams = useRouteParams()
|
||||
const { feedId, view } = routeParams
|
||||
const isList = isListSubscription(feedId)
|
||||
|
||||
if (!entry) return null
|
||||
const date = new Date(
|
||||
isList ? entry.entries.insertedAt : entry.entries.publishedAt,
|
||||
).toDateString()
|
||||
return <DateItem isSticky={isSticky} date={date} view={view} />
|
||||
})
|
||||
|
||||
EntryHeadDateItem.displayName = "EntryHeadDateItem"
|
||||
@@ -1,156 +0,0 @@
|
||||
import { EmptyIcon } from "@follow/components/icons/empty.jsx"
|
||||
import type { HTMLMotionProps } from "framer-motion"
|
||||
import type { DOMAttributes, FC } from "react"
|
||||
import { forwardRef, memo, useCallback } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
import type { VirtuosoHandle, VirtuosoProps } from "react-virtuoso"
|
||||
import { GroupedVirtuoso, Virtuoso } from "react-virtuoso"
|
||||
|
||||
import { useGeneralSettingKey } from "~/atoms/settings/general"
|
||||
import { m } from "~/components/common/Motion"
|
||||
import { ReactVirtuosoItemPlaceholder } from "~/components/ui/placeholder"
|
||||
import { ENTRY_COLUMN_LIST_SCROLLER_ID } from "~/constants/dom"
|
||||
import { useRouteParams } from "~/hooks/biz/useRouteParams"
|
||||
import { useEntry } from "~/store/entry"
|
||||
import { isListSubscription } from "~/store/subscription"
|
||||
|
||||
import { DateItem } from "./components/DateItem"
|
||||
import { EntryColumnShortcutHandler } from "./EntryColumnShortcutHandler"
|
||||
import type { VirtuosoComponentProps } from "./index"
|
||||
|
||||
export const EntryListContent = forwardRef<HTMLDivElement>((props, ref) => (
|
||||
<div className="px-2" {...props} ref={ref} />
|
||||
))
|
||||
|
||||
export const EntryEmptyList = forwardRef<HTMLDivElement, HTMLMotionProps<"div">>((props, ref) => {
|
||||
const unreadOnly = useGeneralSettingKey("unreadOnly")
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<m.div
|
||||
className="absolute -mt-6 flex size-full grow flex-col items-center justify-center gap-2 text-zinc-400"
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
{unreadOnly ? (
|
||||
<>
|
||||
<i className="i-mgc-celebrate-cute-re -mt-11 text-3xl" />
|
||||
<span className="text-base">{t("entry_list.zero_unread")}</span>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex -translate-y-6 flex-col items-center justify-center gap-2">
|
||||
<EmptyIcon className="size-[30px]" />
|
||||
<span className="text-base">{t("words.zero_items")}</span>
|
||||
</div>
|
||||
)}
|
||||
</m.div>
|
||||
)
|
||||
})
|
||||
|
||||
type BaseEntryProps = {
|
||||
virtuosoRef: React.RefObject<VirtuosoHandle>
|
||||
refetch: () => void
|
||||
}
|
||||
type EntryListProps = VirtuosoProps<string, VirtuosoComponentProps> & {
|
||||
groupCounts?: number[]
|
||||
} & BaseEntryProps
|
||||
export const EntryList: FC<EntryListProps> = memo(
|
||||
({
|
||||
virtuosoRef,
|
||||
refetch,
|
||||
groupCounts,
|
||||
|
||||
...virtuosoOptions
|
||||
}) => {
|
||||
// Prevent scroll list move when press up/down key, the up/down key should be taken over by the shortcut key we defined.
|
||||
const handleKeyDown: React.KeyboardEventHandler<HTMLDivElement> = useCallback((e) => {
|
||||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
{groupCounts ? (
|
||||
<EntryGroupedList
|
||||
groupCounts={groupCounts}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...virtuosoOptions}
|
||||
ref={virtuosoRef}
|
||||
/>
|
||||
) : (
|
||||
<Virtuoso
|
||||
id={ENTRY_COLUMN_LIST_SCROLLER_ID}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...virtuosoOptions}
|
||||
ref={virtuosoRef}
|
||||
/>
|
||||
)}
|
||||
<EntryColumnShortcutHandler
|
||||
refetch={refetch}
|
||||
data={virtuosoOptions.data!}
|
||||
virtuosoRef={virtuosoRef}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
const EntryGroupedList = forwardRef<
|
||||
VirtuosoHandle,
|
||||
VirtuosoProps<string, VirtuosoComponentProps> &
|
||||
DOMAttributes<HTMLDivElement> & {
|
||||
groupCounts: number[]
|
||||
}
|
||||
>(({ groupCounts, itemContent, onKeyDown, data, totalCount, ...virtuosoOptions }, ref) => (
|
||||
<GroupedVirtuoso
|
||||
id={ENTRY_COLUMN_LIST_SCROLLER_ID}
|
||||
className="z-0"
|
||||
ref={ref}
|
||||
groupContent={useCallback(
|
||||
(index: number) => {
|
||||
const entryId = getGetGroupDataIndex(groupCounts!, index, data!)
|
||||
|
||||
return <EntryHeadDateItem entryId={entryId} />
|
||||
},
|
||||
[groupCounts, data],
|
||||
)}
|
||||
groupCounts={groupCounts}
|
||||
onKeyDown={onKeyDown}
|
||||
{...virtuosoOptions}
|
||||
itemContent={useCallback(
|
||||
(index: number, _: number, __: string, c: any) => {
|
||||
const entryId = data![index]
|
||||
return itemContent?.(index, entryId, c)
|
||||
},
|
||||
[itemContent, JSON.stringify(data)],
|
||||
)}
|
||||
/>
|
||||
))
|
||||
|
||||
function getGetGroupDataIndex<T>(groupCounts: number[], groupIndex: number, data: readonly T[]) {
|
||||
// Get first grouped of data index
|
||||
//
|
||||
let sum = 0
|
||||
for (let i = 0; i < groupIndex; i++) {
|
||||
sum += groupCounts[i]
|
||||
}
|
||||
return data[sum]
|
||||
}
|
||||
|
||||
const EntryHeadDateItem: FC<{
|
||||
entryId: string
|
||||
}> = memo(({ entryId }) => {
|
||||
const entry = useEntry(entryId)
|
||||
|
||||
const routeParams = useRouteParams()
|
||||
const { feedId, view } = routeParams
|
||||
const isList = isListSubscription(feedId)
|
||||
|
||||
if (!entry) return <ReactVirtuosoItemPlaceholder />
|
||||
const date = new Date(
|
||||
isList ? entry.entries.insertedAt : entry.entries.publishedAt,
|
||||
).toDateString()
|
||||
return <DateItem date={date} view={view} />
|
||||
})
|
||||
|
||||
EntryHeadDateItem.displayName = "EntryHeadDateItem"
|
||||
@@ -1 +1 @@
|
||||
export const girdClassNames = tw`grid grid-cols-1 @lg:grid-cols-2 @3xl:grid-cols-3 @6xl:grid-cols-4 @7xl:grid-cols-5 px-4 gap-1.5`
|
||||
export const girdClassNames = tw`grid grid-cols-1 @lg:grid-cols-2 @3xl:grid-cols-3 @6xl:grid-cols-4 @7xl:grid-cols-5 gap-1.5`
|
||||
|
||||
@@ -6,14 +6,14 @@ import { forwardRef } from "react"
|
||||
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
|
||||
|
||||
import type { EntryColumnWrapperProps } from "./wrapper.shared"
|
||||
import { styles } from "./wrapper.shared"
|
||||
import { animationStyles, styles } from "./wrapper.shared"
|
||||
|
||||
export const EntryColumnWrapper = forwardRef<HTMLDivElement, EntryColumnWrapperProps>(
|
||||
({ children, onScroll }, ref) => {
|
||||
const view = useRouteParamsSelector((state) => state.view)
|
||||
|
||||
return (
|
||||
<div className={clsx(styles, "mt-2")}>
|
||||
<div className={clsx(styles, animationStyles, "mt-2")}>
|
||||
<ScrollArea
|
||||
scrollbarClassName={!views[view].wideMode ? "w-[5px] p-0" : ""}
|
||||
mask={false}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ScrollElementContext } from "@follow/components/ui/scroll-area/ctx.js"
|
||||
import { FeedViewType, views } from "@follow/constants"
|
||||
import { views } from "@follow/constants"
|
||||
import { clsx } from "clsx"
|
||||
import { forwardRef, useState } from "react"
|
||||
|
||||
@@ -25,8 +25,7 @@ export const EntryColumnWrapper = forwardRef<HTMLDivElement, EntryColumnWrapperP
|
||||
<div
|
||||
className="h-full overflow-y-auto"
|
||||
ref={setScrollElement}
|
||||
// PictureMasonry do not have this id from VirtuosoGrid
|
||||
id={view === FeedViewType.Pictures ? ENTRY_COLUMN_LIST_SCROLLER_ID : undefined}
|
||||
id={ENTRY_COLUMN_LIST_SCROLLER_ID}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const styles = tw`relative h-0 grow duration-200 ease-in-out animate-in fade-in slide-in-from-bottom-24 f-motion-reduce:fade-in-0 f-motion-reduce:slide-in-from-bottom-0`
|
||||
export const styles = tw`relative h-0 grow`
|
||||
export const animationStyles = tw`delay-100 duration-200 ease-in-out animate-in fade-in slide-in-from-bottom-24 f-motion-reduce:fade-in-0 f-motion-reduce:slide-in-from-bottom-0`
|
||||
|
||||
export interface EntryColumnWrapperProps extends ComponentType {
|
||||
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from "@follow/components/ui/tooltip/index.jsx"
|
||||
import { EllipsisHorizontalTextWithTooltip } from "@follow/components/ui/typography/index.js"
|
||||
import { FeedViewType } from "@follow/constants"
|
||||
import { getNameInitials } from "@follow/utils/cjk"
|
||||
import { m } from "framer-motion"
|
||||
import { memo } from "react"
|
||||
import { useTranslation } from "react-i18next"
|
||||
@@ -51,7 +52,7 @@ export const EntryUserRow: Component<{ userId: string }> = memo(({ userId }) =>
|
||||
>
|
||||
<Avatar className="block aspect-square size-7 overflow-hidden rounded-full border border-border ring-1 ring-background">
|
||||
<AvatarImage src={replaceImgUrlIfNeed(user?.image || undefined)} />
|
||||
<AvatarFallback>{user.name?.slice(0, 2)}</AvatarFallback>
|
||||
<AvatarFallback>{getNameInitials(user.name || "")}</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
{user.name && (
|
||||
@@ -94,7 +95,7 @@ export const EntryUser: Component<{
|
||||
src={replaceImgUrlIfNeed(user?.image || undefined)}
|
||||
className="bg-theme-placeholder-image"
|
||||
/>
|
||||
<AvatarFallback>{user.name?.slice(0, 2)}</AvatarFallback>
|
||||
<AvatarFallback>{getNameInitials(user.name || "")}</AvatarFallback>
|
||||
</Avatar>
|
||||
</m.button>
|
||||
</TooltipTrigger>
|
||||
|
||||
@@ -167,16 +167,16 @@ export const ListHeader = ({ view }: { view: number }) => {
|
||||
}
|
||||
|
||||
const SORT_LIST = [
|
||||
{ icon: "i-mgc-sort-ascending-cute-re", by: "count", order: "asc" },
|
||||
{ icon: "i-mgc-sort-descending-cute-re", by: "count", order: "desc" },
|
||||
{ icon: tw`i-mgc-numbers-90-sort-ascending-cute-re`, by: "count", order: "asc" },
|
||||
{ icon: tw`i-mgc-numbers-90-sort-descending-cute-re`, by: "count", order: "desc" },
|
||||
|
||||
{
|
||||
icon: "i-mgc-az-sort-descending-letters-cute-re",
|
||||
icon: tw`i-mgc-az-sort-descending-letters-cute-re`,
|
||||
by: "alphabetical",
|
||||
order: "asc",
|
||||
},
|
||||
{
|
||||
icon: "i-mgc-az-sort-ascending-letters-cute-re",
|
||||
icon: tw`i-mgc-az-sort-ascending-letters-cute-re`,
|
||||
by: "alphabetical",
|
||||
order: "desc",
|
||||
},
|
||||
@@ -207,8 +207,8 @@ const SortButton = () => {
|
||||
<IconOpacityTransition
|
||||
icon2={
|
||||
order === "asc"
|
||||
? tw`i-mgc-az-sort-descending-letters-cute-re`
|
||||
: tw`i-mgc-az-sort-ascending-letters-cute-re`
|
||||
? tw`i-mgc-numbers-90-sort-ascending-cute-re`
|
||||
: tw`i-mgc-numbers-90-sort-descending-cute-re`
|
||||
}
|
||||
icon1={
|
||||
order === "asc" ? tw`i-mgc-sort-ascending-cute-re` : tw`i-mgc-sort-descending-cute-re`
|
||||
|
||||
@@ -76,6 +76,8 @@ const fadeInVariant = {
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
}
|
||||
|
||||
const isIconLoadedSet = new Set<string>()
|
||||
export function FeedIcon({
|
||||
feed,
|
||||
entry,
|
||||
@@ -163,9 +165,12 @@ export function FeedIcon({
|
||||
})
|
||||
finalSrc = src
|
||||
|
||||
const isIconLoaded = isIconLoadedSet.has(src)
|
||||
isIconLoadedSet.add(src)
|
||||
|
||||
ImageElement = (
|
||||
<PlatformIcon url={siteUrl} style={sizeStyle} className={cn("center", className)}>
|
||||
<m.img style={sizeStyle} {...(disableFadeIn ? {} : fadeInVariant)} />
|
||||
<m.img style={sizeStyle} {...(disableFadeIn || isIconLoaded ? {} : fadeInVariant)} />
|
||||
</PlatformIcon>
|
||||
)
|
||||
break
|
||||
@@ -176,12 +181,15 @@ export function FeedIcon({
|
||||
width: size * 2,
|
||||
height: size * 2,
|
||||
})
|
||||
const isIconLoaded = isIconLoadedSet.has(finalSrc)
|
||||
isIconLoadedSet.add(finalSrc)
|
||||
|
||||
ImageElement = (
|
||||
<PlatformIcon url={image} style={sizeStyle} className={cn("center", className)}>
|
||||
<m.img
|
||||
className={cn(marginClassName, className)}
|
||||
style={sizeStyle}
|
||||
{...(disableFadeIn ? {} : fadeInVariant)}
|
||||
{...(disableFadeIn || isIconLoaded ? {} : fadeInVariant)}
|
||||
/>
|
||||
</PlatformIcon>
|
||||
)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path stroke="#10161F" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 15.01c0 3-2.5 4.99-2.5 4.99M17 5v14m2.828-2c-.691 1.251-1.577 2.137-2.828 2.828-1.251-.691-2.137-1.577-2.828-2.828M8 17a2 2 0 0 0 2-2v0a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v0a2 2 0 0 0 2 2m0-7a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2"/></svg>
|
||||
|
After Width: | Height: | Size: 481 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path stroke="#10161F" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 15.01c0 3-2.5 4.99-2.5 4.99m9.5 0V6m2.828 2C19.138 6.75 18.252 5.863 17 5.172 15.75 5.863 14.863 6.749 14.172 8M8 17a2 2 0 0 0 2-2v0a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v0a2 2 0 0 0 2 2m0-7a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2"/></svg>
|
||||
|
After Width: | Height: | Size: 478 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path stroke="#10161F" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6.01c0 3-2.5 4.99-2.5 4.99m9.5 9V6m2.828 2C19.138 6.75 18.252 5.863 17 5.172 15.75 5.863 14.863 6.749 14.172 8M8 8a2 2 0 0 0 2-2v0a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v0a2 2 0 0 0 2 2m0 12a2 2 0 0 0 2-2v-2a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2"/></svg>
|
||||
|
After Width: | Height: | Size: 478 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path stroke="#10161F" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6.01c0 3-2.5 4.99-2.5 4.99M17 5v14m2.828-2c-.691 1.251-1.577 2.137-2.828 2.828-1.251-.691-2.137-1.577-2.828-2.828M8 8a2 2 0 0 0 2-2v0a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v0a2 2 0 0 0 2 2m0 12a2 2 0 0 0 2-2v-2a2 2 0 0 0-2-2v0a2 2 0 0 0-2 2v2a2 2 0 0 0 2 2"/></svg>
|
||||
|
After Width: | Height: | Size: 481 B |
+2
-1
@@ -131,7 +131,8 @@
|
||||
"@mozilla/readability": "patches/@mozilla__readability.patch",
|
||||
"daisyui": "patches/daisyui.patch",
|
||||
"jsonpointer": "patches/jsonpointer.patch",
|
||||
"workbox-precaching": "patches/workbox-precaching.patch"
|
||||
"workbox-precaching": "patches/workbox-precaching.patch",
|
||||
"@tanstack/react-virtual@3.10.9": "patches/@tanstack__react-virtual@3.10.9.patch"
|
||||
},
|
||||
"overrides": {
|
||||
"is-core-module": "npm:@nolyfill/is-core-module@^1",
|
||||
|
||||
@@ -23,6 +23,9 @@ export const EventProvider: FC = () => {
|
||||
h,
|
||||
w,
|
||||
})
|
||||
|
||||
const isMobile = window.innerWidth < 1024
|
||||
document.documentElement.dataset.viewport = isMobile ? "mobile" : "desktop"
|
||||
}, 16)
|
||||
|
||||
readViewport()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { cn } from "@follow/utils/utils"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
import * as React from "react"
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
export const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
@@ -14,7 +14,7 @@ const Avatar = React.forwardRef<
|
||||
))
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
export const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
@@ -26,16 +26,17 @@ const AvatarImage = React.forwardRef<
|
||||
))
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
export const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn("flex size-full items-center justify-center rounded-full bg-muted", className)}
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
|
||||
|
||||
export { Avatar, AvatarFallback, AvatarImage }
|
||||
|
||||
@@ -52,7 +52,7 @@ const Scrollbar = React.forwardRef<
|
||||
className={cn(
|
||||
"flex w-2.5 touch-none select-none p-0.5",
|
||||
orientation === "horizontal" ? `h-2.5 w-full flex-col` : `w-2.5 flex-row`,
|
||||
"animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
||||
"z-[3] animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -102,9 +102,6 @@ const TabsTrigger = React.forwardRef<HTMLDivElement, TabsTriggerProps>(
|
||||
"inline-flex items-center justify-center whitespace-nowrap px-3 text-sm font-medium ring-offset-background transition-all disabled:pointer-events-none disabled:opacity-50 data-[state=active]:text-theme-foreground",
|
||||
"group relative z-[1]",
|
||||
tabsTriggerVariants({ variant }),
|
||||
// !isSelect &&
|
||||
// "hover:before:bg-theme-item-hover before:content-[''] before:pointer-events-none before:absolute before:inset-y-0 before:inset-x-1 before:duration-200 before:opacity-60 before:rounded-lg",
|
||||
// className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
export const isCJKChar = (char: string): boolean => {
|
||||
// eslint-disable-next-line unicorn/prefer-code-point
|
||||
const code = char.charCodeAt(0)
|
||||
return (
|
||||
(code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
|
||||
(code >= 0x3400 && code <= 0x4dbf) || // CJK Unified Ideographs Extension A
|
||||
(code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs
|
||||
(code >= 0x3040 && code <= 0x309f) || // Hiragana
|
||||
(code >= 0x30a0 && code <= 0x30ff) // Katakana
|
||||
)
|
||||
}
|
||||
|
||||
export const getNameInitials = (name?: string): string => {
|
||||
if (!name) return ""
|
||||
const first = name[0]
|
||||
const second = name[1]
|
||||
|
||||
if (isCJKChar(first)) return first
|
||||
if (second && isCJKChar(second)) return first
|
||||
return name.slice(0, 2)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { LRUCache } from "./lru-cache"
|
||||
|
||||
describe("LRUCache", () => {
|
||||
it("should create cache with given capacity", () => {
|
||||
const cache = new LRUCache<string, number>(2)
|
||||
expect(cache.size()).toBe(0)
|
||||
})
|
||||
|
||||
it("should throw error for invalid capacity", () => {
|
||||
expect(() => new LRUCache<string, number>(0)).toThrow()
|
||||
expect(() => new LRUCache<string, number>(-1)).toThrow()
|
||||
})
|
||||
|
||||
it("should handle basic get and put operations", () => {
|
||||
const cache = new LRUCache<string, number>(2)
|
||||
|
||||
cache.put("a", 1)
|
||||
expect(cache.get("a")).toBe(1)
|
||||
|
||||
cache.put("b", 2)
|
||||
expect(cache.get("b")).toBe(2)
|
||||
expect(cache.size()).toBe(2)
|
||||
})
|
||||
|
||||
it("should remove least recently used item when capacity is exceeded", () => {
|
||||
const cache = new LRUCache<string, number>(2)
|
||||
|
||||
cache.put("a", 1)
|
||||
cache.put("b", 2)
|
||||
cache.put("c", 3)
|
||||
|
||||
expect(cache.get("a")).toBeUndefined()
|
||||
expect(cache.get("b")).toBe(2)
|
||||
expect(cache.get("c")).toBe(3)
|
||||
})
|
||||
|
||||
it("should update access order on get", () => {
|
||||
const cache = new LRUCache<string, number>(2)
|
||||
|
||||
cache.put("a", 1)
|
||||
cache.put("b", 2)
|
||||
cache.get("a")
|
||||
cache.put("c", 3)
|
||||
|
||||
expect(cache.get("b")).toBeUndefined()
|
||||
expect(cache.get("a")).toBe(1)
|
||||
expect(cache.get("c")).toBe(3)
|
||||
})
|
||||
|
||||
it("should clear cache", () => {
|
||||
const cache = new LRUCache<string, number>(2)
|
||||
|
||||
cache.put("a", 1)
|
||||
cache.put("b", 2)
|
||||
cache.clear()
|
||||
|
||||
expect(cache.size()).toBe(0)
|
||||
expect(cache.get("a")).toBeUndefined()
|
||||
expect(cache.get("b")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
export class LRUCache<K, V> {
|
||||
private capacity: number
|
||||
private cache: Map<K, V>
|
||||
private keys: K[]
|
||||
|
||||
constructor(capacity: number) {
|
||||
if (capacity <= 0) {
|
||||
throw new Error("Capacity must be positive")
|
||||
}
|
||||
|
||||
this.capacity = capacity
|
||||
this.cache = new Map()
|
||||
this.keys = []
|
||||
}
|
||||
|
||||
get(key: K): V | undefined {
|
||||
if (!this.cache.has(key)) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
this.keys = this.keys.filter((k) => k !== key)
|
||||
this.keys.push(key)
|
||||
|
||||
return this.cache.get(key)
|
||||
}
|
||||
|
||||
put(key: K, value: V): void {
|
||||
if (this.cache.has(key)) {
|
||||
this.cache.set(key, value)
|
||||
this.keys = this.keys.filter((k) => k !== key)
|
||||
this.keys.push(key)
|
||||
} else {
|
||||
if (this.keys.length >= this.capacity) {
|
||||
const leastUsedKey = this.keys.shift()
|
||||
if (leastUsedKey !== undefined) {
|
||||
this.cache.delete(leastUsedKey)
|
||||
}
|
||||
}
|
||||
|
||||
this.cache.set(key, value)
|
||||
this.keys.push(key)
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear()
|
||||
this.keys = []
|
||||
}
|
||||
|
||||
size(): number {
|
||||
return this.cache.size
|
||||
}
|
||||
|
||||
getKeys(): K[] {
|
||||
return [...this.keys]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
diff --git a/dist/esm/index.js b/dist/esm/index.js
|
||||
index 13b1ae504e8b55efcd9d8cecd30df8ab234edc18..a8549c560b0d6b7a65b4dc58e8fb134d04dda037 100644
|
||||
--- a/dist/esm/index.js
|
||||
+++ b/dist/esm/index.js
|
||||
@@ -22,6 +22,9 @@ function useVirtualizerBase(options) {
|
||||
);
|
||||
instance.setOptions(resolvedOptions);
|
||||
React.useEffect(() => {
|
||||
+ if (!instance.scrollElement) {
|
||||
+ instance._willUpdate()
|
||||
+ }
|
||||
return instance._didMount();
|
||||
}, []);
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
Generated
+14
-23
@@ -18,6 +18,9 @@ patchedDependencies:
|
||||
'@mozilla/readability':
|
||||
hash: 43niildbdafdxi7qfcwhpkkxwa
|
||||
path: patches/@mozilla__readability.patch
|
||||
'@tanstack/react-virtual@3.10.9':
|
||||
hash: fpkworxfwphev7q33sgiutu7aq
|
||||
path: patches/@tanstack__react-virtual@3.10.9.patch
|
||||
daisyui:
|
||||
hash: igsntdatmoaxzwxof4bkkh35fy
|
||||
path: patches/daisyui.patch
|
||||
@@ -456,6 +459,9 @@ importers:
|
||||
'@tanstack/react-query-persist-client':
|
||||
specifier: 5.61.4
|
||||
version: 5.61.4(@tanstack/react-query@5.61.4(react@18.3.1))(react@18.3.1)
|
||||
'@tanstack/react-virtual':
|
||||
specifier: 3.10.9
|
||||
version: 3.10.9(patch_hash=fpkworxfwphev7q33sgiutu7aq)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@use-gesture/react':
|
||||
specifier: 10.3.1
|
||||
version: 10.3.1(react@18.3.1)
|
||||
@@ -606,9 +612,6 @@ importers:
|
||||
react-shadow:
|
||||
specifier: 20.5.0
|
||||
version: 20.5.0(prop-types@15.8.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-virtuoso:
|
||||
specifier: 4.12.2
|
||||
version: 4.12.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react-zoom-pan-pinch:
|
||||
specifier: ^3.6.1
|
||||
version: 3.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -4330,14 +4333,14 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^18 || ^19
|
||||
|
||||
'@tanstack/react-virtual@3.10.8':
|
||||
resolution: {integrity: sha512-VbzbVGSsZlQktyLrP5nxE+vE1ZR+U0NFAWPbJLoG2+DKPwd2D7dVICTVIIaYlJqX1ZCEnYDbaOpmMwbsyhBoIA==}
|
||||
'@tanstack/react-virtual@3.10.9':
|
||||
resolution: {integrity: sha512-OXO2uBjFqA4Ibr2O3y0YMnkrRWGVNqcvHQXmGvMu6IK8chZl3PrDxFXdGZ2iZkSrKh3/qUYoFqYe+Rx23RoU0g==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
|
||||
'@tanstack/virtual-core@3.10.8':
|
||||
resolution: {integrity: sha512-PBu00mtt95jbKFi6Llk9aik8bnR3tR/oQP1o3TSi+iG//+Q2RTIzCEgKkHG8BB86kxMNW6O8wku+Lmi+QFR6jA==}
|
||||
'@tanstack/virtual-core@3.10.9':
|
||||
resolution: {integrity: sha512-kBknKOKzmeR7lN+vSadaKWXaLS0SZZG+oqpQ/k80Q6g9REn6zRHS/ZYdrIzHnpHgy/eWs00SujveUN/GJT2qTw==}
|
||||
|
||||
'@tootallnate/once@2.0.0':
|
||||
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
||||
@@ -9522,13 +9525,6 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
react-virtuoso@4.12.2:
|
||||
resolution: {integrity: sha512-9KiEc3uxD07qNrwb09PhPJKWfeNQ/Fw/TNKdZS7D3v4cDa6M/jg5lKLAUlRL7RluO8870cgLGM1T5pPKYEnprg==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
react: '>=16 || >=17 || >= 18'
|
||||
react-dom: '>=16 || >=17 || >= 18'
|
||||
|
||||
react-zoom-pan-pinch@3.6.1:
|
||||
resolution: {integrity: sha512-SdPqdk7QDSV7u/WulkFOi+cnza8rEZ0XX4ZpeH7vx3UZEg7DoyuAy3MCmm+BWv/idPQL2Oe73VoC0EhfCN+sZQ==}
|
||||
engines: {node: '>=8', npm: '>=5'}
|
||||
@@ -13309,7 +13305,7 @@ snapshots:
|
||||
'@floating-ui/react': 0.26.25(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@react-aria/focus': 3.18.4(react@18.3.1)
|
||||
'@react-aria/interactions': 3.22.4(react@18.3.1)
|
||||
'@tanstack/react-virtual': 3.10.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@tanstack/react-virtual': 3.10.9(patch_hash=fpkworxfwphev7q33sgiutu7aq)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
@@ -15095,13 +15091,13 @@ snapshots:
|
||||
'@tanstack/query-core': 5.61.4
|
||||
react: 18.3.1
|
||||
|
||||
'@tanstack/react-virtual@3.10.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
'@tanstack/react-virtual@3.10.9(patch_hash=fpkworxfwphev7q33sgiutu7aq)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.10.8
|
||||
'@tanstack/virtual-core': 3.10.9
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
'@tanstack/virtual-core@3.10.8': {}
|
||||
'@tanstack/virtual-core@3.10.9': {}
|
||||
|
||||
'@tootallnate/once@2.0.0': {}
|
||||
|
||||
@@ -21261,11 +21257,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 18.3.12
|
||||
|
||||
react-virtuoso@4.12.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
react-dom: 18.3.1(react@18.3.1)
|
||||
|
||||
react-zoom-pan-pinch@3.6.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
Reference in New Issue
Block a user