diff --git a/packages/views/inbox/components/inbox-list.tsx b/packages/views/inbox/components/inbox-list.tsx new file mode 100644 index 0000000000..07563ebdcc --- /dev/null +++ b/packages/views/inbox/components/inbox-list.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useState } from "react"; +import { Virtuoso } from "react-virtuoso"; +import { Inbox } from "lucide-react"; +import type { InboxItem } from "@multica/core/types"; +import { InboxListItem } from "./inbox-list-item"; +import { useT } from "../../i18n"; + +/** + * Scrollable, virtualized inbox notification list. + * + * Owns the scroll container so both the mobile and desktop layouts render an + * identical scroller. Rows are virtualized via react-virtuoso so only the + * visible window (plus a small overscan) is mounted — the notification list + * can grow long and every row otherwise carries an avatar + hover card, so + * mounting all of them inflates the tab-switch commit (MUL-4474). + * + * Virtualization changes exactly one thing: whether an off-screen row is in + * the DOM. Selection, hover, archive, and scroll semantics are unchanged — + * the row component and the callbacks are the same as the non-virtualized + * list. `customScrollParent` keeps Virtuoso reading/writing the existing + * `overflow-y-auto` element (same pattern as the issue-detail timeline), so + * scroll position behaves exactly as before. + * + * Known virtualization tradeoff: keyboard Tab only reaches currently-mounted + * rows; a keyboard-only user must scroll to bring off-screen rows into the + * tab order. The inbox has no custom arrow-key list navigation, so the + * practical surface is small, but it is called out for the manual pass. + */ +export function InboxList({ + items, + selectedKey, + onSelect, + onArchive, +}: { + items: InboxItem[]; + selectedKey: string; + onSelect: (item: InboxItem) => void; + onArchive: (id: string) => void; +}) { + const { t } = useT("inbox"); + // Virtuoso's `customScrollParent` wants the actual HTMLElement, not a ref. + // A callback ref into state hands the element over once it mounts and + // triggers the re-render that lets Virtuoso attach to it. + const [scrollEl, setScrollEl] = useState(null); + + if (items.length === 0) { + return ( +
+
+ +

{t(($) => $.list.empty)}

+
+
+ ); + } + + return ( +
+
+ {scrollEl && ( + item.id} + increaseViewportBy={{ top: 400, bottom: 400 }} + itemContent={(_index, item) => ( + onSelect(item)} + onArchive={() => onArchive(item.id)} + /> + )} + /> + )} +
+
+ ); +} diff --git a/packages/views/inbox/components/inbox-page.tsx b/packages/views/inbox/components/inbox-page.tsx index 92fe4e844f..749a1aede7 100644 --- a/packages/views/inbox/components/inbox-page.tsx +++ b/packages/views/inbox/components/inbox-page.tsx @@ -52,7 +52,8 @@ import { } from "@multica/ui/components/ui/dropdown-menu"; import { useIsMobile } from "@multica/ui/hooks/use-mobile"; import { PageHeader } from "../../layout/page-header"; -import { InboxListItem, useTimeAgo } from "./inbox-list-item"; +import { useTimeAgo } from "./inbox-list-item"; +import { InboxList } from "./inbox-list"; import { useTypeLabels } from "./inbox-detail-label"; import { getInboxDisplayTitle } from "./inbox-display"; import { useT } from "../../i18n"; @@ -271,23 +272,13 @@ export function InboxPage() { ); - const listBody = items.length === 0 ? ( -
- -

{t(($) => $.list.empty)}

-
- ) : ( -
- {items.map((item) => ( - handleSelect(item)} - onArchive={() => handleArchive(item.id)} - /> - ))} -
+ const list = ( + ); const detailContent = selected?.issue_id ? ( @@ -418,9 +409,7 @@ export function InboxPage() { return (
{listHeader} -
- {listBody} -
+ {list}
); } @@ -464,9 +453,7 @@ export function InboxPage() {
{listHeader} -
- {listBody} -
+ {list}