[READ-ONLY] Mirror of https://github.com/FoxxMD/multi-scrobbler. Scrobble plays from multiple sources to multiple clients docs.multi-scrobbler.app
deezer docker jellyfin koito lastfm listenbrainz maloja mopidy mpris music music-assistant plex scrobble self-hosted spotify subsonic tautulli youtube-music
0

Configure Feed

Select the types of activity you want to include in your feed.

implement virtual list with tanstack/virtual

FoxxMD (Jun 19, 2026, 7:30 PM UTC) ffe2203b 987819cc

+461 -71
+30
package-lock.json
··· 132 132 "@tailwindcss/vite": "^4.2.2", 133 133 "@tanstack/react-query": "^5.101.0", 134 134 "@tanstack/react-query-devtools": "^5.101.0", 135 + "@tanstack/react-virtual": "^3.14.3", 135 136 "@tsconfig/node18": "^1.0.1", 136 137 "@types/chai": "^4.3.0", 137 138 "@types/chai-as-promised": "^7.1.5", ··· 5621 5622 "peerDependencies": { 5622 5623 "@tanstack/react-query": "^5.101.0", 5623 5624 "react": "^18 || ^19" 5625 + } 5626 + }, 5627 + "node_modules/@tanstack/react-virtual": { 5628 + "version": "3.14.3", 5629 + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.3.tgz", 5630 + "integrity": "sha512-k/cnHPVaOfn46hSbiY6n4Dzf4QjCGWSF40zR5QIIYUqPAjpA6TN7InfYmcMiDVQGP2iUn9xsRbAl8u1v3UmeVQ==", 5631 + "dev": true, 5632 + "license": "MIT", 5633 + "dependencies": { 5634 + "@tanstack/virtual-core": "3.17.1" 5635 + }, 5636 + "funding": { 5637 + "type": "github", 5638 + "url": "https://github.com/sponsors/tannerlinsley" 5639 + }, 5640 + "peerDependencies": { 5641 + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", 5642 + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" 5643 + } 5644 + }, 5645 + "node_modules/@tanstack/virtual-core": { 5646 + "version": "3.17.1", 5647 + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.1.tgz", 5648 + "integrity": "sha512-VZyW2Uiml5tmBZwPGrSD3Sz73OxzljQMCmzYHsUTPEuTsERf5xwa+uWb01xEzkz3ZSYTjj8NEb/mKHvgKxyZdA==", 5649 + "dev": true, 5650 + "license": "MIT", 5651 + "funding": { 5652 + "type": "github", 5653 + "url": "https://github.com/sponsors/tannerlinsley" 5624 5654 } 5625 5655 }, 5626 5656 "node_modules/@testing-library/dom": {
+1
package.json
··· 171 171 "@tailwindcss/vite": "^4.2.2", 172 172 "@tanstack/react-query": "^5.101.0", 173 173 "@tanstack/react-query-devtools": "^5.101.0", 174 + "@tanstack/react-virtual": "^3.14.3", 174 175 "@tsconfig/node18": "^1.0.1", 175 176 "@types/chai": "^4.3.0", 176 177 "@types/chai-as-promised": "^7.1.5",
+56 -3
src/client/components/ActivityDetail.tsx
··· 1 1 import React, { ComponentProps, useState, Fragment } from "react" 2 - import { Accordion, For, Span, Stack, Text, Box, AbsoluteCenter, Button, Separator, HStack, Flex, Badge, IconButton, Container, Icon, useAccordionItemContext, Skeleton } from '@chakra-ui/react'; 2 + import { Accordion, For, Span, Stack, Text, Box, AbsoluteCenter, Button, Separator, HStack, Flex, Badge, IconButton, Container, Icon, useAccordionItemContext, Skeleton, Collapsible } from '@chakra-ui/react'; 3 3 import { ComponentType } from "../../core/Atomic"; 4 4 import { PlayData } from "./PlayData"; 5 5 import { ErrorAlert } from "./ErrorAlert"; ··· 18 18 import { QueryPlaysOpts } from "../../backend/common/database/drizzle/repositories/PlayRepository"; 19 19 import { tanQueries } from "../queries"; 20 20 import { PaginatedResponse } from "../../backend/common/database/drizzle/repositories/BaseRepository"; 21 + import { LuChevronRight } from "react-icons/lu"; 21 22 22 23 export interface ActivityDetailProps { 23 24 activity: PlayApiCommonDetailed ··· 40 41 return ( 41 42 <Container fluid p="0"> 42 43 <Flex justify="space-between"> 43 - <Stack gap="1" truncate> 44 + <Stack alignContent="flex-start" textAlign="start" gap="1"> 44 45 <Span>{play.data.track}</Span> 45 46 <TextMuted truncate>{play.data.artists.map(x => x.name).join(' / ')}</TextMuted> 46 47 <HStack gap="1"> ··· 54 55 }} justify="flex-start" alignItems="flex-end"> 55 56 <PlayStateBadge maxWidth="fit-content" data={activity} /> 56 57 </Stack> 57 - 58 58 </Flex> 59 59 </Container> 60 60 ) ··· 162 162 } 163 163 164 164 return <ActivityDetails componentType={props.componentType} key={data?.uid} activity={data}/> 165 + } 166 + 167 + export const ActivityCollapsible = (props: ActivitySummaryProps & { key?: string, live?: boolean, componentId: number, query: QueryPlaysOpts }) => { 168 + const { 169 + activity: { 170 + play 171 + } = {}, 172 + activity, 173 + sortBy, 174 + live = false 175 + } = props; 176 + return ( 177 + <Collapsible.Root key={props.key} 178 + 179 + lazyMount 180 + _open={{ 181 + background: "var(--chakra-colors-bg-subtle)" 182 + }} 183 + style={{ 184 + borderColor: "var(--chakra-colors-border)", 185 + borderWidth: '1px', 186 + }} 187 + > 188 + <Collapsible.Trigger 189 + userSelect="text" 190 + w="full" 191 + paddingY="3" 192 + display="flex" 193 + gap="2" 194 + alignItems="center" 195 + truncate cursor="pointer" 196 + style={{ 197 + paddingBlock: "var(--chakra-spacing-2)", 198 + paddingInline: "var(--chakra-spacing-4)" 199 + }} 200 + > 201 + <Collapsible.Indicator 202 + transition="transform 0.2s" 203 + _open={{ transform: "rotate(90deg)" }} 204 + > 205 + <LuChevronRight /> 206 + </Collapsible.Indicator> 207 + {live ? <ActivitySummaryFetchable activityUid={activity.uid} {...props} /> : <ActivitySummary componentType={props.componentType} activity={activity} sortBy={sortBy} />} 208 + </Collapsible.Trigger> 209 + <Collapsible.Content borderTopColor="gray.border" 210 + style={{ 211 + paddingBlock: "var(--chakra-spacing-4)", 212 + paddingInline: "var(--chakra-spacing-4)" 213 + }}> 214 + {live ? <ActivityDetailFetchable componentId={props.componentId} componentType={props.componentType} query={props.query} uid={activity.uid} /> : <ActivityDetails {...props} activity={activity as PlayApiCommonDetailed} />} 215 + </Collapsible.Content> 216 + </Collapsible.Root> 217 + ) 165 218 }
+114
src/client/components/playActivity/ListParts.tsx
··· 1 + import { Accordion, Span, Stack, Text, Box, Separator, HStack, Flex, IconButton, Container, SkeletonText, Collapsible, ScrollArea } from '@chakra-ui/react'; 2 + import { ComponentType } from '../../../core/Atomic.js'; 3 + import React, { ComponentProps, Fragment, useMemo, useCallback } from "react" 4 + import dayjs, { Dayjs } from 'dayjs'; 5 + import doy from 'dayjs/plugin/dayOfYear.js'; 6 + import { PlayApiCommon, PlayApiCommonDetailed, SortPlaysByProps } from '../../../core/Api.js'; 7 + import { QueryPlaysOpts } from '../../../backend/common/database/drizzle/repositories/PlayRepository.js'; 8 + import { VscDebugRestart } from 'react-icons/vsc'; 9 + import { sortByNewestDate } from '../../../core/PlayUtils.js'; 10 + 11 + dayjs.extend(doy); 12 + 13 + export interface GroupInfo { 14 + count: number 15 + date: Dayjs 16 + } 17 + 18 + export interface GroupData { 19 + plays: PlayApiCommon[] 20 + date: Dayjs 21 + } 22 + 23 + export interface ActivityLogProps extends SortPlaysByProps { 24 + data: PlayApiCommon[] 25 + componentId: number 26 + componentType: ComponentType 27 + render?: 'virtNormal' | 'virtDynamic' | 'virtExp' | 'accordian' 28 + query: QueryPlaysOpts 29 + live?: boolean 30 + } 31 + 32 + 33 + export const GroupHeader = (props: { data: GroupInfo } & ComponentProps<typeof Box>) => { 34 + const { 35 + data, 36 + ...rest 37 + } = props; 38 + const gData = data; 39 + let headerText: string; 40 + if (gData.date.isToday()) { 41 + headerText = 'Today'; 42 + } else { 43 + headerText = gData.date.format('MMM DD'); 44 + if (gData.date.year() !== dayjs().year()) { 45 + headerText += `, ${gData.date.year()}`; 46 + } 47 + } 48 + return ( 49 + <Box {...rest}> 50 + <Flex direction="row" justify="space-between"> 51 + 52 + <Text fontWeight="semibold">{headerText}</Text> 53 + 54 + <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 55 + <VscDebugRestart /> 56 + </IconButton> 57 + </Flex> 58 + <Separator orientation="horizontal" height="4" /> 59 + </Box> 60 + ) 61 + } 62 + 63 + export const isGroupInfo = (val: any): val is GroupInfo => val.date !== undefined; 64 + 65 + export const generateGroupInfo = (data: PlayApiCommon[]): GroupInfo[] => { 66 + 67 + const groupsReduced = data.reduce((acc: { groups: GroupInfo[], active?: GroupInfo }, curr, index) => { 68 + const date = dayjs(curr.play.data.playDate); 69 + if (acc.active === undefined) { 70 + return { ...acc, active: { count: 1, date } }; 71 + } 72 + if (acc.active.date.dayOfYear() !== date.dayOfYear()) { 73 + return { groups: [...acc.groups, acc.active], active: { count: 1, date } } 74 + } 75 + 76 + return { groups: acc.groups, active: { ...acc.active, count: acc.active.count + 1 } }; 77 + }, { groups: [] }); 78 + 79 + return groupsReduced.groups.concat(groupsReduced.active); 80 + } 81 + 82 + 83 + export const generateGroupPlays = (data: PlayApiCommon[]): GroupData[] => { 84 + 85 + if(data.length === 0) { 86 + return []; 87 + } 88 + const groupsReduced = data.reduce((acc: { groups: GroupData[], active?: GroupData }, curr, index) => { 89 + const date = dayjs(curr.play.data.playDate); 90 + if (acc.active === undefined) { 91 + return { ...acc, active: { plays: [curr], date } }; 92 + } 93 + if (acc.active.date.dayOfYear() !== date.dayOfYear()) { 94 + return { groups: [...acc.groups, acc.active], active: { plays: [curr], date } } 95 + } 96 + 97 + return { groups: acc.groups, active: { ...acc.active, plays: acc.active.plays.concat(curr) } }; 98 + }, { groups: [] }); 99 + 100 + if(groupsReduced.active !== null && groupsReduced !== undefined) { 101 + return groupsReduced.groups.concat(groupsReduced.active); 102 + } 103 + 104 + return groupsReduced.groups; 105 + } 106 + 107 + export const generateFlatItems = (data: PlayApiCommon[]) => { 108 + const groups = generateGroupPlays(data); 109 + groups.sort((a, b) => sortByNewestDate(a.date, b.date)); 110 + return groups.map((x) => { 111 + x.plays.sort((a, b) => sortByNewestDate(a.playedAt, b.playedAt)); 112 + return [{count: x.plays.length, date: x.date}, ...x.plays]; 113 + }).flat(1); 114 + }
+19 -68
src/client/components/playActivity/PlayList.tsx
··· 1 - import { Accordion, Span, Stack, Text, Box, Separator, HStack, Flex, IconButton, Container, SkeletonText } from '@chakra-ui/react'; 1 + import { Accordion, Span, Stack, Text, Box, HStack, Flex, Container, SkeletonText, Collapsible, ScrollArea } from '@chakra-ui/react'; 2 2 import { ComponentType } from '../../../core/Atomic.js'; 3 - import React, { ComponentProps, Fragment } from "react" 3 + import React, { ComponentProps, Fragment, useMemo, useCallback } from "react" 4 4 import dayjs, { Dayjs } from 'dayjs'; 5 5 import doy from 'dayjs/plugin/dayOfYear.js'; 6 6 import { VscDebugRestart } from "react-icons/vsc"; 7 7 import { ActivityDetailFetchable, ActivityDetails, ActivitySummary, ActivitySummaryFetchable } from '../ActivityDetail.js'; 8 8 import "./PlayList.scss"; 9 - import { PlayApiCommon, PlayApiCommonDetailed, SortPlaysByProps } from '../../../core/Api.js'; 10 - import { QueryPlaysOpts } from '../../../backend/common/database/drizzle/repositories/PlayRepository.js'; 9 + import { PlayApiCommonDetailed } from '../../../core/Api.js'; 11 10 import { useQuery } from '@tanstack/react-query'; 12 11 import { ErrorAlert } from '../ErrorAlert.js'; 13 12 import { tanQueries } from '../../queries/index.js'; 13 + import { VirtualizedListNormal } from './VirtualListNormal.js'; 14 + import { VirtualizedListDynamic } from './VirtualListDynamic.js'; 15 + import { VirtualizedListExp } from './VirtualListExperimental.js'; 16 + import { ActivityLogProps, generateGroupPlays, GroupHeader } from './ListParts.js'; 14 17 15 18 dayjs.extend(doy); 16 - 17 - export interface ActivityLogProps extends SortPlaysByProps { 18 - data: PlayApiCommon[] 19 - componentId: number 20 - componentType: ComponentType 21 - render?: 'virtCollapse' | 'virtAccordian' | 'accordian' 22 - query: QueryPlaysOpts 23 - live?: boolean 24 - } 25 - 26 - interface GroupInfo { 27 - count: number 28 - date: Dayjs 29 - } 30 - 31 - interface GroupData { 32 - plays: PlayApiCommon[] 33 - date: Dayjs 34 - } 35 - 36 - const generateGroupPlays = (data: PlayApiCommon[]): GroupData[] => { 37 - 38 - if(data.length === 0) { 39 - return []; 40 - } 41 - const groupsReduced = data.reduce((acc: { groups: GroupData[], active?: GroupData }, curr, index) => { 42 - const date = dayjs(curr.play.data.playDate); 43 - if (acc.active === undefined) { 44 - return { ...acc, active: { plays: [curr], date } }; 45 - } 46 - if (acc.active.date.dayOfYear() !== date.dayOfYear()) { 47 - return { groups: [...acc.groups, acc.active], active: { plays: [curr], date } } 48 - } 49 - 50 - return { groups: acc.groups, active: { ...acc.active, plays: acc.active.plays.concat(curr) } }; 51 - }, { groups: [] }); 52 - 53 - if(groupsReduced.active !== null && groupsReduced !== undefined) { 54 - return groupsReduced.groups.concat(groupsReduced.active); 55 - } 56 - 57 - return groupsReduced.groups; 58 - } 59 19 60 20 export const PlayList = (props: ActivityLogProps) => { 61 21 ··· 67 27 68 28 if (render === 'accordian') { 69 29 return <PlainAccordian data={data} sortBy={sortBy} {...props} /> 30 + } 31 + if(render === 'virtNormal') { 32 + return <VirtualizedListNormal data={data} sortBy={sortBy} {...props} /> 33 + } 34 + if(render === 'virtDynamic') { 35 + return <VirtualizedListDynamic data={data} sortBy={sortBy} {...props} /> 36 + } 37 + if(render === 'virtExp') { 38 + return <VirtualizedListExp data={data} sortBy={sortBy} {...props} /> 70 39 } 71 40 } 72 41 ··· 81 50 return ( 82 51 <Stack gap="2"> 83 52 {groups.map((g) => { 84 - let headerText: string; 85 - if (g.date.isToday()) { 86 - headerText = 'Today'; 87 - } else { 88 - headerText = g.date.format('MMM DD'); 89 - if (g.date.year() !== dayjs().year()) { 90 - headerText += `, ${g.date.year()}`; 91 - } 92 - } 93 53 return ( 94 - <Fragment key={headerText}> 95 - <Box> 96 - <Flex direction="row" justify="space-between"> 97 - 98 - <Text fontWeight="semibold">{headerText}</Text> 99 - 100 - <IconButton variant="ghost" size="xs" maxWidth="fit-content"> 101 - <VscDebugRestart /> 102 - </IconButton> 103 - </Flex> 104 - <Separator orientation="horizontal" height="4" /> 105 - </Box> 54 + <Fragment key={g.date.valueOf()}> 55 + <GroupHeader data={{date: g.date, count: g.plays.length}}/> 106 56 <Accordion.Root variant="enclosed" collapsible multiple lazyMount> 107 57 {g.plays.map((activity, index) => { 108 58 const { play } = activity; ··· 127 77 </Stack> 128 78 ); 129 79 } 80 + 130 81 131 82 export const ListContainer = (props?: ComponentProps<typeof PlayList>) => { 132 83 return <Container maxWidth="3xl"><PlayList {...props} /></Container>
+67
src/client/components/playActivity/VirtualListDynamic.tsx
··· 1 + import { Accordion, Span, Stack, Text, Box, Separator, HStack, Flex, IconButton, Container, SkeletonText, Collapsible, ScrollArea } from '@chakra-ui/react'; 2 + import React, { ComponentProps, Fragment, useMemo, useCallback } from "react" 3 + import { ActivityCollapsible } from '../ActivityDetail.js'; 4 + import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual" 5 + import { ActivityLogProps, generateFlatItems, GroupHeader, isGroupInfo } from './ListParts.js'; 6 + 7 + const itemContainerStyle: React.ComponentProps<"div">['style'] = { 8 + position: "absolute", 9 + top: 0, 10 + left: 0, 11 + width: "100%", 12 + paddingBottom: 4, 13 + }; 14 + 15 + const containerStyle: React.ComponentProps<"div">['style'] = { 16 + width: "100%", 17 + position: "relative", 18 + } 19 + 20 + export const VirtualizedListDynamic = (props: ActivityLogProps) => { 21 + const { 22 + data = [], 23 + sortBy, 24 + live = false, 25 + } = props; 26 + 27 + const items = useMemo(() => { 28 + return generateFlatItems(data); 29 + }, [data]); 30 + 31 + const parentRef = React.useRef(null) 32 + 33 + // The virtualizer 34 + const virtualizer = useVirtualizer({ 35 + count: items.length, 36 + getScrollElement: () => parentRef.current, 37 + estimateSize: () => 85, 38 + directDomUpdates: true, 39 + debug: true 40 + }); 41 + 42 + return (<div 43 + ref={parentRef} 44 + className="List" 45 + style={{ 46 + height: 800, 47 + width: 800, 48 + overflowY: 'auto', 49 + contain: 'strict', 50 + overflowAnchor: 'none', 51 + }} 52 + > 53 + <Box ref={virtualizer.containerRef} style={containerStyle}> 54 + {virtualizer.getVirtualItems().map((virtualItem) => { 55 + const item = items[virtualItem.index] 56 + return ( 57 + <Box w="full" 58 + data-index={virtualItem.index} 59 + key={virtualItem.key} 60 + ref={virtualizer.measureElement} 61 + style={itemContainerStyle}> 62 + {isGroupInfo(item) ? <GroupHeader paddingY="2" data={item} /> : <ActivityCollapsible query={props.query} live={live} sortBy={sortBy} componentId={props.componentId} componentType={props.componentType} activity={item} />} 63 + </Box>) 64 + })} 65 + </Box> 66 + </div>); 67 + }
+100
src/client/components/playActivity/VirtualListExperimental.tsx
··· 1 + import { Accordion, Span, Stack, Text, Box, Separator, HStack, Flex, IconButton, Container, SkeletonText, Collapsible, ScrollArea } from '@chakra-ui/react'; 2 + import React, { ComponentProps, Fragment, useMemo, useCallback } from "react" 3 + import { ActivityCollapsible } from '../ActivityDetail.js'; 4 + import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual" 5 + import { ActivityLogProps, generateFlatItems, GroupHeader, isGroupInfo } from './ListParts.js'; 6 + 7 + export const VirtualizedListExp = (props: ActivityLogProps) => { 8 + const { 9 + data = [], 10 + sortBy, 11 + live = false, 12 + } = props; 13 + 14 + const items = useMemo(() => { 15 + return generateFlatItems(data); 16 + }, [data]); 17 + 18 + const parentRef = React.useRef<HTMLDivElement>(null) 19 + const innerRef = React.useRef<HTMLDivElement>(null) 20 + const rowRefsMap = React.useRef(new Map<number, HTMLDivElement>()) 21 + 22 + // The virtualizer 23 + const virtualizer = useVirtualizer({ 24 + count: items.length, 25 + getScrollElement: () => parentRef.current, 26 + estimateSize: () => 85, 27 + onChange: (instance) => { 28 + innerRef.current!.style.height = `${instance.getTotalSize()}px` 29 + instance.getVirtualItems().forEach((virtualRow) => { 30 + const rowRef = rowRefsMap.current.get(virtualRow.index) 31 + if (!rowRef) return 32 + rowRef.style.transform = `translateY(${virtualRow.start}px)` 33 + }) 34 + }, 35 + }); 36 + 37 + const indexes = virtualizer.getVirtualIndexes(); 38 + 39 + React.useEffect(() => { 40 + virtualizer.measure() 41 + }, []) 42 + 43 + const contentProps = useMemo( 44 + (): React.ComponentProps<"div"> => ({ 45 + style: { 46 + width: "100%", 47 + position: "relative", 48 + }, 49 + }), 50 + [virtualizer], 51 + ) 52 + 53 + const getItemProps = useCallback( 54 + (item: VirtualItem): React.ComponentProps<"div"> => ({ 55 + style: { 56 + position: "absolute", 57 + top: 0, 58 + left: 0, 59 + width: "100%", 60 + paddingBottom: 4, 61 + }, 62 + }), 63 + [], 64 + ) 65 + return (<div 66 + ref={parentRef} 67 + className="List" 68 + style={{ 69 + height: 800, 70 + width: 800, 71 + overflowY: 'auto', 72 + contain: 'strict', 73 + //overflowAnchor: 'none', 74 + }} 75 + > 76 + <Box ref={innerRef} {...contentProps}> 77 + 78 + {indexes.map((index) => { 79 + const item = items[index]; 80 + 81 + return ( 82 + <div 83 + key={index} 84 + data-index={index} 85 + ref={(el) => { 86 + if (el) { 87 + virtualizer.measureElement(el) 88 + rowRefsMap.current.set(index, el) 89 + } 90 + }} 91 + > 92 + <Box w="full"> 93 + {isGroupInfo(item) ? <GroupHeader paddingY="2" data={item}/> : <ActivityCollapsible query={props.query} live={live} sortBy={sortBy} componentId={props.componentId} componentType={props.componentType} activity={item}/>} 94 + </Box> 95 + </div> 96 + ); 97 + })} 98 + </Box> 99 + </div>); 100 + }
+74
src/client/components/playActivity/VirtualListNormal.tsx
··· 1 + import { Accordion, Span, Stack, Text, Box, Separator, HStack, Flex, IconButton, Container, SkeletonText, Collapsible, ScrollArea } from '@chakra-ui/react'; 2 + import React, { ComponentProps, Fragment, useMemo, useCallback } from "react" 3 + import { ActivityCollapsible } from '../ActivityDetail.js'; 4 + import { type VirtualItem, useVirtualizer } from "@tanstack/react-virtual" 5 + import { ActivityLogProps, generateFlatItems, GroupHeader, isGroupInfo } from './ListParts.js'; 6 + 7 + export const VirtualizedListNormal = (props: ActivityLogProps) => { 8 + const { 9 + data = [], 10 + sortBy, 11 + live = false, 12 + } = props; 13 + 14 + const items = useMemo(() => { 15 + return generateFlatItems(data); 16 + }, [data]); 17 + 18 + const parentRef = React.useRef(null) 19 + 20 + // The virtualizer 21 + const virtualizer = useVirtualizer({ 22 + count: items.length, 23 + getScrollElement: () => parentRef.current, 24 + estimateSize: () => 85, 25 + }); 26 + 27 + const contentProps = useMemo( 28 + (): React.ComponentProps<"div"> => ({ 29 + style: { 30 + height: `${virtualizer.getTotalSize()}px`, 31 + width: "100%", 32 + position: "relative", 33 + }, 34 + }), 35 + [virtualizer], 36 + ) 37 + 38 + const getItemProps = useCallback( 39 + (item: VirtualItem): React.ComponentProps<"div"> => ({ 40 + style: { 41 + position: "absolute", 42 + top: 0, 43 + left: 0, 44 + width: "100%", 45 + paddingBottom: 4, 46 + height: `${item.size}px`, 47 + transform: `translateY(${item.start}px)`, 48 + }, 49 + }), 50 + [], 51 + ) 52 + return (<ScrollArea.Root height="50vh" > 53 + <ScrollArea.Viewport ref={parentRef}> 54 + <ScrollArea.Content> 55 + <Box {...contentProps}> 56 + {virtualizer.getVirtualItems().map((virtualItem) => { 57 + const item = items[virtualItem.index] 58 + return ( 59 + <div 60 + data-index={virtualItem.index} 61 + key={virtualItem.key} 62 + ref={virtualizer.measureElement} 63 + {...getItemProps(virtualItem)}> 64 + <Box w="full"> 65 + {isGroupInfo(item) ? <GroupHeader paddingY="2" data={item}/> : <ActivityCollapsible query={props.query} live={live} sortBy={sortBy} componentId={props.componentId} componentType={props.componentType} activity={item}/>} 66 + </Box> 67 + </div>) 68 + })} 69 + </Box> 70 + </ScrollArea.Content> 71 + </ScrollArea.Viewport> 72 + <ScrollArea.Scrollbar bg="transparent" /> 73 + </ScrollArea.Root>); 74 + }