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:
Innei
2024-11-29 22:51:37 +08:00
committed by GitHub
parent 0155792a38
commit d98ebaa4f7
41 changed files with 1019 additions and 591 deletions
+1 -1
View File
@@ -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",
+12 -1
View File
@@ -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>
)
}
@@ -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 })
}
}
}
+73 -213
View File
@@ -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`
+10 -2
View File
@@ -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
View File
@@ -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()
+7 -6
View File
@@ -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}
>
+21
View File
@@ -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)
}
+63
View File
@@ -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()
})
})
+57
View File
@@ -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(() => {
+14 -23
View File
@@ -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