From d98ebaa4f74a09d4f6eb4d29afd0eac30f289ddf Mon Sep 17 00:00:00 2001 From: Innei Date: Fri, 29 Nov 2024 22:51:37 +0800 Subject: [PATCH] refactor: vlist and keep scroll position (#1931) * init Signed-off-by: Innei * feat: grid Signed-off-by: Innei * refactor: mark read logci Signed-off-by: Innei * chore: remove react-virtuoso Signed-off-by: Innei * fix: video Signed-off-by: Innei * transition Signed-off-by: Innei * fix: image memo loaded status Signed-off-by: Innei * fix: sort button Signed-off-by: Innei * feat: cjk truncate Signed-off-by: Innei --------- Signed-off-by: Innei --- apps/renderer/package.json | 2 +- apps/renderer/src/components/ui/media.tsx | 13 +- .../app-layout/entry-column/mobile.tsx | 3 +- .../feed-column/float-bar.mobile.tsx | 21 +- .../app-layout/feed-column/mobile.module.css | 9 - .../EntryColumnShortcutHandler.tsx | 13 +- .../entry-column/Items/article-item.tsx | 2 +- .../entry-column/Items/notification-item.tsx | 2 +- .../entry-column/Items/picture-item.tsx | 2 +- .../entry-column/Items/picture-masonry.tsx | 4 +- .../entry-column/components/DateItem.tsx | 88 ++--- .../src/modules/entry-column/grid.tsx | 286 ++++++++++++++++ .../{hooks.ts => hooks/useEntriesByView.ts} | 85 +---- .../hooks/useEntryMarkReadHandler.tsx | 55 ++++ .../src/modules/entry-column/index.tsx | 286 +++++----------- .../entry-column/layouts/EntryItemWrapper.tsx | 2 +- .../layouts/EntryListHeader.mobile.tsx | 2 +- .../entry-column/layouts/TimelineTabs.tsx | 6 +- .../src/modules/entry-column/list.tsx | 310 ++++++++++++++++++ .../src/modules/entry-column/lists.tsx | 156 --------- .../src/modules/entry-column/styles.ts | 2 +- .../modules/entry-column/wrapper.desktop.tsx | 4 +- .../modules/entry-column/wrapper.mobile.tsx | 5 +- .../modules/entry-column/wrapper.shared.tsx | 3 +- .../components/EntryReadHistory.shared.tsx | 5 +- .../src/modules/feed-column/list.shared.tsx | 12 +- apps/renderer/src/modules/feed/feed-icon.tsx | 12 +- .../mgc/numbers_09_sort_ascending_cute_re.svg | 1 + .../numbers_09_sort_descending_cute_re.svg | 1 + .../mgc/numbers_90_sort_ascending_cute_re.svg | 1 + .../numbers_90_sort_descending_cute_re.svg | 1 + package.json | 3 +- .../src/providers/event-provider.tsx | 3 + packages/components/src/ui/avatar/index.tsx | 13 +- .../src/ui/scroll-area/ScrollArea.tsx | 2 +- packages/components/src/ui/tabs/index.tsx | 3 - packages/utils/src/cjk.ts | 21 ++ packages/utils/src/lru-cache.test.ts | 63 ++++ packages/utils/src/lru-cache.ts | 57 ++++ patches/@tanstack__react-virtual@3.10.9.patch | 14 + pnpm-lock.yaml | 37 +-- 41 files changed, 1019 insertions(+), 591 deletions(-) delete mode 100644 apps/renderer/src/modules/app-layout/feed-column/mobile.module.css create mode 100644 apps/renderer/src/modules/entry-column/grid.tsx rename apps/renderer/src/modules/entry-column/{hooks.ts => hooks/useEntriesByView.ts} (77%) create mode 100644 apps/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx create mode 100644 apps/renderer/src/modules/entry-column/list.tsx delete mode 100644 apps/renderer/src/modules/entry-column/lists.tsx create mode 100644 icons/mgc/numbers_09_sort_ascending_cute_re.svg create mode 100644 icons/mgc/numbers_09_sort_descending_cute_re.svg create mode 100644 icons/mgc/numbers_90_sort_ascending_cute_re.svg create mode 100644 icons/mgc/numbers_90_sort_descending_cute_re.svg create mode 100644 packages/utils/src/cjk.ts create mode 100644 packages/utils/src/lru-cache.test.ts create mode 100644 packages/utils/src/lru-cache.ts create mode 100644 patches/@tanstack__react-virtual@3.10.9.patch diff --git a/apps/renderer/package.json b/apps/renderer/package.json index c5668a6ac..efc6a1412 100644 --- a/apps/renderer/package.json +++ b/apps/renderer/package.json @@ -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", diff --git a/apps/renderer/src/components/ui/media.tsx b/apps/renderer/src/components/ui/media.tsx index 9116c4aa1..a0b4e5257 100644 --- a/apps/renderer/src/components/ui/media.tsx +++ b/apps/renderer/src/components/ui/media.tsx @@ -22,6 +22,8 @@ type BaseProps = { inline?: boolean fitContent?: boolean } + +const isImageLoadedSet = new Set() export type MediaProps = BaseProps & ( | (ImgHTMLAttributes & { @@ -103,7 +105,12 @@ const MediaImpl: FC = ({ [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 = useEventCallback(() => { switch (currentState) { @@ -158,6 +165,10 @@ const MediaImpl: FC = ({ const handleOnLoad: React.ReactEventHandler = useEventCallback((e) => { setMediaLoadState("loaded") rest.onLoad?.(e as any) + + if (imgSrc) { + isImageLoadedSet.add(imgSrc) + } if ("cacheDimensions" in props && props.cacheDimensions && src) { saveImageDimensionsToDb(src, { src, diff --git a/apps/renderer/src/modules/app-layout/entry-column/mobile.tsx b/apps/renderer/src/modules/app-layout/entry-column/mobile.tsx index 9b88f74ae..568ae315c 100644 --- a/apps/renderer/src/modules/app-layout/entry-column/mobile.tsx +++ b/apps/renderer/src/modules/app-layout/entry-column/mobile.tsx @@ -24,8 +24,9 @@ export const EntryColumnMobile = () => { return () => clearTimeout(timer) }, [view]) usePreventOverscrollBounce() + return ( -
+
{isStartupTimeline && ( diff --git a/apps/renderer/src/modules/app-layout/feed-column/float-bar.mobile.tsx b/apps/renderer/src/modules/app-layout/feed-column/float-bar.mobile.tsx index 5d65af603..50daadcec 100644 --- a/apps/renderer/src/modules/app-layout/feed-column/float-bar.mobile.tsx +++ b/apps/renderer/src/modules/app-layout/feed-column/float-bar.mobile.tsx @@ -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 ( - -
+ -
-
+ +
) } diff --git a/apps/renderer/src/modules/app-layout/feed-column/mobile.module.css b/apps/renderer/src/modules/app-layout/feed-column/mobile.module.css deleted file mode 100644 index 3d6256149..000000000 --- a/apps/renderer/src/modules/app-layout/feed-column/mobile.module.css +++ /dev/null @@ -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); -} diff --git a/apps/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx b/apps/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx index 83ef9a838..998e2718b 100644 --- a/apps/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx +++ b/apps/renderer/src/modules/entry-column/EntryColumnShortcutHandler.tsx @@ -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 -}> = 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({ diff --git a/apps/renderer/src/modules/entry-column/Items/article-item.tsx b/apps/renderer/src/modules/entry-column/Items/article-item.tsx index f2f77158d..03be07f50 100644 --- a/apps/renderer/src/modules/entry-column/Items/article-item.tsx +++ b/apps/renderer/src/modules/entry-column/Items/article-item.tsx @@ -58,7 +58,7 @@ export function ArticleItemStateLess({ entry, feed }: EntryItemStatelessProps) { } export const ArticleItemSkeleton = ( -
+
diff --git a/apps/renderer/src/modules/entry-column/Items/notification-item.tsx b/apps/renderer/src/modules/entry-column/Items/notification-item.tsx index a7f62cb34..d20aca27b 100644 --- a/apps/renderer/src/modules/entry-column/Items/notification-item.tsx +++ b/apps/renderer/src/modules/entry-column/Items/notification-item.tsx @@ -33,7 +33,7 @@ export function NotificationItemStateLess({ entry, feed }: EntryItemStatelessPro export const NotificationItemSkeleton = (
- +
diff --git a/apps/renderer/src/modules/entry-column/Items/picture-item.tsx b/apps/renderer/src/modules/entry-column/Items/picture-item.tsx index c2e6f0b20..48e128054 100644 --- a/apps/renderer/src/modules/entry-column/Items/picture-item.tsx +++ b/apps/renderer/src/modules/entry-column/Items/picture-item.tsx @@ -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" diff --git a/apps/renderer/src/modules/entry-column/Items/picture-masonry.tsx b/apps/renderer/src/modules/entry-column/Items/picture-masonry.tsx index 6cb6c8bb1..4cef49e98 100644 --- a/apps/renderer/src/modules/entry-column/Items/picture-masonry.tsx +++ b/apps/renderer/src/modules/entry-column/Items/picture-masonry.tsx @@ -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 + endReached: () => any hasNextPage: boolean } diff --git a/apps/renderer/src/modules/entry-column/components/DateItem.tsx b/apps/renderer/src/modules/entry-column/components/DateItem.tsx index 41a5cb539..bf50554cf 100644 --- a/apps/renderer/src/modules/entry-column/components/DateItem.tsx +++ b/apps/renderer/src/modules/entry-column/components/DateItem.tsx @@ -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 + isSticky?: boolean +} + +type DateItemProps = Pick & { + 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(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 } - return + return }) -const UniversalDateItem = ({ date, className }: { date: string; className?: string }) => { +const UniversalDateItem = ({ date, className, isSticky }: Omit) => { const { startOfDay, endOfDay, dateObj } = useParseDate(date) return ( - + ) } -const DateItemInner: FC<{ - date: Date - startTime: number - endTime: number - className?: string - Wrapper?: FC -}> = ({ date, endTime, startTime, className, Wrapper }) => { +const DateItemInner: FC = ({ + 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 (
= (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 ( +
+ +
+ {t("entry_column.filtered_content_tip")} +
+ + +
+ ) + } + + const hasFilteredContent = nextData.length < entriesIds.length + + const FilteredContentTip = hasFilteredContent && !hasNextPage && ( +
+ +
{t("entry_column.filtered_content_tip_2")}
+ + +
+ ) + + if (masonry && view === FeedViewType.Pictures) { + return ( + <> + + + {FilteredContentTip} + + ) + } + return ( + <> + + {FilteredContentTip} + + ) +} + +const capacity = 3 * 2 +const offsetCache = new LRUCache(capacity) +const measurementsCache = new LRUCache(capacity) + +const ratioMap = { + [FeedViewType.Pictures]: 1, + // 16:9 + [FeedViewType.Videos]: 16 / 9, +} +const VirtualGrid: FC = (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) => { + 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) => { + 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 ( +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const isLoaderRow = virtualRow.key === rows.length + if (isLoaderRow && ready) { + return ( +
+ {Footer ? typeof Footer === "function" ?
: Footer : null} + {hasNextPage && } +
+ ) + } + return ( + + {columnVirtualizer.getVirtualItems().map((virtualColumn) => ( +
+ {ready && ( + + )} +
+ ))} +
+ ) + })} +
+ ) +} diff --git a/apps/renderer/src/modules/entry-column/hooks.ts b/apps/renderer/src/modules/entry-column/hooks/useEntriesByView.ts similarity index 77% rename from apps/renderer/src/modules/entry-column/hooks.ts rename to apps/renderer/src/modules/entry-column/hooks/useEntriesByView.ts index 92ab94102..66486a41a 100644 --- a/apps/renderer/src/modules/entry-column/hooks.ts +++ b/apps/renderer/src/modules/entry-column/hooks/useEntriesByView.ts @@ -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>({ - 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 diff --git a/apps/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx b/apps/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx new file mode 100644 index 000000000..49a4e7abb --- /dev/null +++ b/apps/renderer/src/modules/entry-column/hooks/useEntryMarkReadHandler.tsx @@ -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 }) + } + } +} diff --git a/apps/renderer/src/modules/entry-column/index.tsx b/apps/renderer/src/modules/entry-column/index.tsx index 8cb18cd95..193fc9696 100644 --- a/apps/renderer/src/modules/entry-column/index.tsx +++ b/apps/renderer/src/modules/entry-column/index.tsx @@ -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(null) const [isArchived, setIsArchived] = useState(false) const unreadOnly = useGeneralSettingKey("unreadOnly") + const listRef = useRef>() 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(null) + const handleScroll = useCallback(() => { + if (!isInteracted.current) { + isInteracted.current = true + } - const handleScroll = useCallback( - (e: React.UIEvent) => { - 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 ( -
- -
- ) - } else { - return null - } - } else { - if (context?.onlyShowArchivedButton) return null - return ( - - ) - } - }, - [isFetchingNextPage, showArchivedButton, t, view, entries.data?.pages], - ), - ScrollSeekPlaceholder: useCallback(() => , [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 - - return - }, - [view], - ), - } satisfies VirtuosoProps + 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([]) 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 (
- {virtuosoOptions.data.length === 0 && - !entries.isLoading && - !entries.error && - feed?.type === "feed" && } + {entriesIds.length === 0 && !entries.isLoading && !entries.error && feed?.type === "feed" && ( + + )} - {virtuosoOptions.data.length === 0 && !showArchivedButton ? ( + {entriesIds.length === 0 && !showArchivedButton ? ( entries.isLoading ? null : ( ) - ) : view && views[view].gridMode ? ( - ) : ( - + +
+ ) : null + } /> )} @@ -253,105 +212,6 @@ function EntryColumnImpl() { ) } -const ListGird = ({ - virtuosoOptions, - virtuosoRef, - hasNextPage, -}: { - virtuosoOptions: Omit< - VirtuosoGridProps, - "data" | "endReached" - > & { - data: string[] - endReached: () => Promise - } - virtuosoRef: React.RefObject - 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 ( -
- -
- {t("entry_column.filtered_content_tip")} -
- - -
- ) - } - - const hasFilteredContent = nextData.length < virtuosoOptions.data.length - - const FilteredContentTip = hasFilteredContent && !hasNextPage && ( -
- -
{t("entry_column.filtered_content_tip_2")}
- - -
- ) - - if (masonry && view === FeedViewType.Pictures) { - return ( - <> - virtuosoOptions.data.length} - endReached={virtuosoOptions.endReached} - data={nextData} - /> - - {virtuosoOptions.components?.Footer && - (virtuosoOptions.components.Footer as FunctionComponent)({ - context: { onlyShowArchivedButton: true }, - })} - - {FilteredContentTip} - - ) - } - return ( - <> - - {FilteredContentTip} - - ) -} - const AddFeedHelper = () => { const feedId = useRouteParamsSelector((s) => s.feedId) const feedQuery = useFeed({ id: feedId }) diff --git a/apps/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx b/apps/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx index 7f3428b15..5e470099c 100644 --- a/apps/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx +++ b/apps/renderer/src/modules/entry-column/layouts/EntryItemWrapper.tsx @@ -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, diff --git a/apps/renderer/src/modules/entry-column/layouts/EntryListHeader.mobile.tsx b/apps/renderer/src/modules/entry-column/layouts/EntryListHeader.mobile.tsx index f7704969f..a6d33475d 100644 --- a/apps/renderer/src/modules/entry-column/layouts/EntryListHeader.mobile.tsx +++ b/apps/renderer/src/modules/entry-column/layouts/EntryListHeader.mobile.tsx @@ -70,7 +70,7 @@ export const EntryListHeader: FC = ({ refetch, isRefreshin
{ return ( { if (!val) { @@ -43,9 +43,9 @@ export const TimelineTabs = () => { } }} > - + - Yours + Yours {listsData.map((s) => ( diff --git a/apps/renderer/src/modules/entry-column/list.tsx b/apps/renderer/src/modules/entry-column/list.tsx new file mode 100644 index 000000000..ae5738028 --- /dev/null +++ b/apps/renderer/src/modules/entry-column/list.tsx @@ -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((props, ref) => ( +
+)) + +export const EntryEmptyList = forwardRef>((props, ref) => { + const unreadOnly = useGeneralSettingKey("unreadOnly") + const { t } = useTranslation() + return ( + + {unreadOnly ? ( + <> + + {t("entry_list.zero_unread")} + + ) : ( +
+ + {t("words.zero_items")} +
+ )} +
+ ) +}) + +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 | undefined> +} + +const capacity = 3 +const offsetCache = new LRUCache(capacity) +const measurementsCache = new LRUCache(capacity) +export const EntryList: FC = 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 = 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) => { + 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 ( + <> +
+ {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 ? ( + + ) : Footer ? ( + typeof Footer === "function" ? ( +
+ ) : ( + Footer + ) + ) : null + + return ( +
+ {Content} +
+ ) + } + const activeSticky = isActiveSticky(virtualRow.index) + const sticky = isSticky(virtualRow.index) + return ( + + {sticky && ( +
+ +
+ )} +
+ +
+
+ ) + })} +
+ + + + ) + }, +) + +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 +}) + +EntryHeadDateItem.displayName = "EntryHeadDateItem" diff --git a/apps/renderer/src/modules/entry-column/lists.tsx b/apps/renderer/src/modules/entry-column/lists.tsx deleted file mode 100644 index 10a1c6f26..000000000 --- a/apps/renderer/src/modules/entry-column/lists.tsx +++ /dev/null @@ -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((props, ref) => ( -
-)) - -export const EntryEmptyList = forwardRef>((props, ref) => { - const unreadOnly = useGeneralSettingKey("unreadOnly") - const { t } = useTranslation() - return ( - - {unreadOnly ? ( - <> - - {t("entry_list.zero_unread")} - - ) : ( -
- - {t("words.zero_items")} -
- )} -
- ) -}) - -type BaseEntryProps = { - virtuosoRef: React.RefObject - refetch: () => void -} -type EntryListProps = VirtuosoProps & { - groupCounts?: number[] -} & BaseEntryProps -export const EntryList: FC = 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 = useCallback((e) => { - if (e.key === "ArrowDown" || e.key === "ArrowUp") { - e.preventDefault() - } - }, []) - - return ( - <> - {groupCounts ? ( - - ) : ( - - )} - - - ) - }, -) - -const EntryGroupedList = forwardRef< - VirtuosoHandle, - VirtuosoProps & - DOMAttributes & { - groupCounts: number[] - } ->(({ groupCounts, itemContent, onKeyDown, data, totalCount, ...virtuosoOptions }, ref) => ( - { - const entryId = getGetGroupDataIndex(groupCounts!, index, data!) - - return - }, - [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(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 - const date = new Date( - isList ? entry.entries.insertedAt : entry.entries.publishedAt, - ).toDateString() - return -}) - -EntryHeadDateItem.displayName = "EntryHeadDateItem" diff --git a/apps/renderer/src/modules/entry-column/styles.ts b/apps/renderer/src/modules/entry-column/styles.ts index f3f087672..c0bf10de4 100644 --- a/apps/renderer/src/modules/entry-column/styles.ts +++ b/apps/renderer/src/modules/entry-column/styles.ts @@ -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` diff --git a/apps/renderer/src/modules/entry-column/wrapper.desktop.tsx b/apps/renderer/src/modules/entry-column/wrapper.desktop.tsx index cb9871f98..8aaba803e 100644 --- a/apps/renderer/src/modules/entry-column/wrapper.desktop.tsx +++ b/apps/renderer/src/modules/entry-column/wrapper.desktop.tsx @@ -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( ({ children, onScroll }, ref) => { const view = useRouteParamsSelector((state) => state.view) return ( -
+
{children}
diff --git a/apps/renderer/src/modules/entry-column/wrapper.shared.tsx b/apps/renderer/src/modules/entry-column/wrapper.shared.tsx index 832333329..39222a4a1 100644 --- a/apps/renderer/src/modules/entry-column/wrapper.shared.tsx +++ b/apps/renderer/src/modules/entry-column/wrapper.shared.tsx @@ -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) => void diff --git a/apps/renderer/src/modules/entry-content/components/EntryReadHistory.shared.tsx b/apps/renderer/src/modules/entry-content/components/EntryReadHistory.shared.tsx index cb0ccbb02..c1ea44be7 100644 --- a/apps/renderer/src/modules/entry-content/components/EntryReadHistory.shared.tsx +++ b/apps/renderer/src/modules/entry-content/components/EntryReadHistory.shared.tsx @@ -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 }) => > - {user.name?.slice(0, 2)} + {getNameInitials(user.name || "")} {user.name && ( @@ -94,7 +95,7 @@ export const EntryUser: Component<{ src={replaceImgUrlIfNeed(user?.image || undefined)} className="bg-theme-placeholder-image" /> - {user.name?.slice(0, 2)} + {getNameInitials(user.name || "")} diff --git a/apps/renderer/src/modules/feed-column/list.shared.tsx b/apps/renderer/src/modules/feed-column/list.shared.tsx index 75023b2c2..88b1e51d4 100644 --- a/apps/renderer/src/modules/feed-column/list.shared.tsx +++ b/apps/renderer/src/modules/feed-column/list.shared.tsx @@ -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 = () => { () export function FeedIcon({ feed, entry, @@ -163,9 +165,12 @@ export function FeedIcon({ }) finalSrc = src + const isIconLoaded = isIconLoadedSet.has(src) + isIconLoadedSet.add(src) + ImageElement = ( - + ) break @@ -176,12 +181,15 @@ export function FeedIcon({ width: size * 2, height: size * 2, }) + const isIconLoaded = isIconLoadedSet.has(finalSrc) + isIconLoadedSet.add(finalSrc) + ImageElement = ( ) diff --git a/icons/mgc/numbers_09_sort_ascending_cute_re.svg b/icons/mgc/numbers_09_sort_ascending_cute_re.svg new file mode 100644 index 000000000..d3b06dd70 --- /dev/null +++ b/icons/mgc/numbers_09_sort_ascending_cute_re.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/mgc/numbers_09_sort_descending_cute_re.svg b/icons/mgc/numbers_09_sort_descending_cute_re.svg new file mode 100644 index 000000000..c4219c936 --- /dev/null +++ b/icons/mgc/numbers_09_sort_descending_cute_re.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/mgc/numbers_90_sort_ascending_cute_re.svg b/icons/mgc/numbers_90_sort_ascending_cute_re.svg new file mode 100644 index 000000000..89e1843ce --- /dev/null +++ b/icons/mgc/numbers_90_sort_ascending_cute_re.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/mgc/numbers_90_sort_descending_cute_re.svg b/icons/mgc/numbers_90_sort_descending_cute_re.svg new file mode 100644 index 000000000..4f4f82421 --- /dev/null +++ b/icons/mgc/numbers_90_sort_descending_cute_re.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/package.json b/package.json index bfc65b3f2..69b81ca4f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/components/src/providers/event-provider.tsx b/packages/components/src/providers/event-provider.tsx index c4adbe3d8..2367f8908 100644 --- a/packages/components/src/providers/event-provider.tsx +++ b/packages/components/src/providers/event-provider.tsx @@ -23,6 +23,9 @@ export const EventProvider: FC = () => { h, w, }) + + const isMobile = window.innerWidth < 1024 + document.documentElement.dataset.viewport = isMobile ? "mobile" : "desktop" }, 16) readViewport() diff --git a/packages/components/src/ui/avatar/index.tsx b/packages/components/src/ui/avatar/index.tsx index 438c4549d..2e7e7abcd 100644 --- a/packages/components/src/ui/avatar/index.tsx +++ b/packages/components/src/ui/avatar/index.tsx @@ -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, React.ComponentPropsWithoutRef >(({ 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, React.ComponentPropsWithoutRef >(({ 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, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( )) AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName - -export { Avatar, AvatarFallback, AvatarImage } diff --git a/packages/components/src/ui/scroll-area/ScrollArea.tsx b/packages/components/src/ui/scroll-area/ScrollArea.tsx index d2264efcb..1cc5b0e0d 100644 --- a/packages/components/src/ui/scroll-area/ScrollArea.tsx +++ b/packages/components/src/ui/scroll-area/ScrollArea.tsx @@ -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, )} > diff --git a/packages/components/src/ui/tabs/index.tsx b/packages/components/src/ui/tabs/index.tsx index 9b9e5cd6b..98b7aaf7c 100644 --- a/packages/components/src/ui/tabs/index.tsx +++ b/packages/components/src/ui/tabs/index.tsx @@ -102,9 +102,6 @@ const TabsTrigger = React.forwardRef( "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} > diff --git a/packages/utils/src/cjk.ts b/packages/utils/src/cjk.ts new file mode 100644 index 000000000..6b2497704 --- /dev/null +++ b/packages/utils/src/cjk.ts @@ -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) +} diff --git a/packages/utils/src/lru-cache.test.ts b/packages/utils/src/lru-cache.test.ts new file mode 100644 index 000000000..7e475acbe --- /dev/null +++ b/packages/utils/src/lru-cache.test.ts @@ -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(2) + expect(cache.size()).toBe(0) + }) + + it("should throw error for invalid capacity", () => { + expect(() => new LRUCache(0)).toThrow() + expect(() => new LRUCache(-1)).toThrow() + }) + + it("should handle basic get and put operations", () => { + const cache = new LRUCache(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(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(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(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() + }) +}) diff --git a/packages/utils/src/lru-cache.ts b/packages/utils/src/lru-cache.ts new file mode 100644 index 000000000..0f3efb2aa --- /dev/null +++ b/packages/utils/src/lru-cache.ts @@ -0,0 +1,57 @@ +export class LRUCache { + private capacity: number + private cache: Map + 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] + } +} diff --git a/patches/@tanstack__react-virtual@3.10.9.patch b/patches/@tanstack__react-virtual@3.10.9.patch new file mode 100644 index 000000000..5535bdf1e --- /dev/null +++ b/patches/@tanstack__react-virtual@3.10.9.patch @@ -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(() => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf5ba6178..1775df929 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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