React (Web)Examples
Activity History
Posting every 2.4s · 12 pending · Maintaining at end
Features used
maintainScrollAtEndto keep the live timeline pinned to the latest activity until the user scrolls away.maintainVisibleContentPositionto preserve the current viewport when older activity is prepended above it.onStartReachedto fetch older activity when scrolling back in time.stickyHeaderIndicesto pin day separators while the timeline moves through mixed live and historical rows.
example-web/src/examples/curated/ActivityHistoryExample.tsx
View source in legend-listimport React from "react";import { LegendList, type LegendListRef } from "@legendapp/list/react";import { type ActivityHistoryRow, appendActivityItems, buildActivityHistoryRows, buildActivityItems, prependActivityItems, settlePendingActivityItems,} from "@examples/commerce";import { buttonStyle, CARD_CLASS, cardStyle, listViewportStyle, Shell } from "./shared";export function ActivityHistoryExample() { const [items, setItems] = React.useState(() => buildActivityItems()); const [expandedIds, setExpandedIds] = React.useState<string[]>([]); const [isLive, setIsLive] = React.useState(true); const [isMaintainingAtEnd, setIsMaintainingAtEnd] = React.useState(true); const listRef = React.useRef<LegendListRef | null>(null); const timeline = React.useMemo(() => buildActivityHistoryRows(items), [items]); const pendingCount = React.useMemo(() => items.filter((item) => item.status === "pending").length, [items]); const toggleExpanded = React.useCallback((id: string) => { setExpandedIds((current) => current.includes(id) ? current.filter((value) => value !== id) : [...current, id], ); }, []); const updateMaintainAtEndState = React.useCallback(() => { const next = listRef.current?.getState().isAtEnd; if (next === undefined) { return; } setIsMaintainingAtEnd((current) => (current === next ? current : next)); }, []); React.useEffect(() => { if (!isLive) { return; } const appendTimer = window.setInterval(() => { setItems((current) => appendActivityItems(current, 1)); }, 2400); const settleTimer = window.setInterval(() => { setItems((current) => settlePendingActivityItems(current, 1)); }, 1600); return () => { window.clearInterval(appendTimer); window.clearInterval(settleTimer); }; }, [isLive]); React.useEffect(() => { updateMaintainAtEndState(); }, [items, updateMaintainAtEndState]); return ( <Shell title="Activity History"> <div className="flex min-h-0 flex-1 flex-col"> <div className="mb-3 flex flex-wrap gap-3"> <button className={buttonStyle(isLive)} onClick={() => setIsLive((current) => !current)} type="button" > {isLive ? "Pause live" : "Resume live"} </button> <div className="self-center text-[13px] text-slate-500"> {isLive ? "Posting every 2.4s" : "Live feed paused"} · {pendingCount} pending ·{" "} {isMaintainingAtEnd ? "Maintaining at end" : "Not maintaining at end"} </div> </div> <LegendList contentContainerStyle={{ padding: 8 }} data={timeline.rows} estimatedItemSize={116} initialScrollIndex={timeline.rows.length - 1} keyExtractor={(item) => item.id} maintainScrollAtEnd maintainVisibleContentPosition onLoad={updateMaintainAtEndState} onScroll={updateMaintainAtEndState} onStartReached={() => setItems((current) => prependActivityItems(current, 12))} onStartReachedThreshold={0.2} recycleItems ref={listRef} renderItem={({ item }: { item: ActivityHistoryRow }) => item.type === "header" ? ( <div className="mb-2 rounded-none border border-slate-300 bg-slate-200 px-3 py-[10px]" style={{ borderRadius: 0, }} > <div className="text-[15px] font-extrabold">{item.title}</div> <div className="mt-[3px] text-xs text-slate-600"> {item.totalLabel} {item.pendingCount > 0 ? ` · ${item.pendingCount} pending` : ""} </div> </div> ) : ( <button className={`${CARD_CLASS} mb-3 w-full cursor-pointer text-left`} onClick={() => toggleExpanded(item.item.id)} style={{ ...cardStyle(), borderColor: item.item.status === "pending" ? "#F59E0B" : item.item.status === "reversed" ? "#FCA5A5" : item.item.kind === "credit" ? "#86EFAC" : "#E5E7EB", }} type="button" > <div className="flex items-center justify-between gap-3"> <div className="min-w-0"> <div className="font-extrabold">{item.item.summary}</div> <div className="mt-1 text-[13px] text-slate-500"> {item.item.merchant} · {item.item.categoryLabel} · {item.item.timeLabel} </div> </div> <div className="whitespace-nowrap font-extrabold" style={{ color: item.item.kind === "credit" ? "#0F766E" : "#9A3412", }} > {item.item.amountLabel} </div> </div> <div className="mt-[10px] flex gap-2"> <div className="rounded-full px-[10px] py-1.5 text-xs font-bold capitalize" style={{ background: item.item.status === "pending" ? "#FEF3C7" : item.item.status === "reversed" ? "#FEE2E2" : "#DCFCE7", borderRadius: 999, color: item.item.status === "pending" ? "#92400E" : item.item.status === "reversed" ? "#991B1B" : "#166534", }} > {item.item.status} </div> <div className="py-1.5 text-xs text-slate-500"> {expandedIds.includes(item.item.id) ? "Hide details" : "Show details"} </div> </div> {expandedIds.includes(item.item.id) ? ( <div className="mt-3 grid gap-2 leading-[1.55] text-slate-700"> {item.item.detailLines.map((line, index) => ( <div key={`${item.item.id}-${index}`}>{line}</div> ))} </div> ) : null} </button> ) } stickyHeaderIndices={timeline.stickyHeaderIndices} style={listViewportStyle} /> </div> </Shell> );}