Refactor ThreadSummary to MVVM (#33603)

* Refactor ThreadSummary to MVVM

* Stories Snapshot images

* Add ThreadSummary ViewModel coverage

* Fix ThreadSummary preview avatar rendering

* Remove ThreadSummary classnames helper

* Fix Prettier

* Match ThreadMessagePreview typography

* Move folder to correct path and fix storybook path

* Catch ThreadSummary preview refresh errors

* Update snapshot images + fix prettier

* Fix ThreadSummary classNames import

* Update Images

* Remove wrong path
This commit is contained in:
Zack
2026-05-28 10:57:55 +02:00
committed by GitHub
parent 34c388f760
commit 3bb9cb2234
20 changed files with 2485 additions and 262 deletions
+1
View File
@@ -53,6 +53,7 @@ export * from "./room/timeline/event-tile/EventTileView/TextualEventView";
export * from "./room/timeline/event-tile/body/AudioPlayerView";
export * from "./room/timeline/event-tile/body/DecryptionFailureBodyView";
export * from "./room/timeline/event-tile/body/MediaBody";
export * from "./room/timeline/event-tile/EventTileView/ThreadSummary";
export * from "./room/timeline/event-tile/reactions/ReactionsRow";
export * from "./room/timeline/event-tile/reactions/ReactionsRowButton";
export * from "./room/timeline/event-tile/reactions/ReactionsRowButtonTooltip";
@@ -0,0 +1,121 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
.threadSummary,
.content {
color: var(--cpd-color-text-secondary);
}
.threadSummary {
margin-top: var(--cpd-space-2x);
min-width: 267px;
max-width: min(calc(100% - var(--EventTile_group_line-spacing-inline-start, 0px)), 600px);
width: fit-content;
height: 40px;
position: relative;
background-color: var(--cpd-color-bg-subtle-secondary);
padding-inline: var(--cpd-space-3x) var(--cpd-space-4x);
display: flex;
align-items: center;
justify-content: flex-start;
border-radius: 8px;
box-sizing: border-box;
clear: both;
overflow: hidden;
border: 1px solid var(--cpd-color-bg-subtle-secondary);
font: var(--cpd-font-body-sm-regular);
appearance: none;
cursor: pointer;
text-align: start;
}
.threadSummary:hover,
.threadSummary:focus {
border-color: var(--cpd-color-border-interactive-secondary);
}
.threadSummary:hover .chevron,
.threadSummary:focus .chevron {
opacity: 1;
transform: translateX(0);
}
.sender,
.content {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.content {
font: var(--cpd-font-body-sm-regular);
}
.sender {
font-weight: var(--cpd-font-weight-semibold);
}
.content strong {
font: var(--cpd-font-body-sm-semibold);
}
.threadSummary .repliesAmount,
.threadSummary .sender,
.threadSummary .content {
line-height: var(--EventTile_ThreadSummary-line-height, 24px);
}
.threadSummary .content {
margin-left: var(--cpd-space-1x);
flex: 1;
}
.repliesAmount {
color: var(--cpd-color-text-secondary);
font-weight: var(--cpd-font-weight-semibold);
white-space: nowrap;
position: relative;
padding: 0 var(--cpd-space-3x) 0 var(--cpd-space-2x);
}
.avatar {
margin-inline-end: var(--cpd-space-2x);
flex-shrink: 0;
}
.threadIcon {
flex-shrink: 0;
}
.chevron {
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 60px;
box-sizing: border-box;
background: linear-gradient(270deg, var(--cpd-color-bg-canvas-default) 50%, transparent 100%);
opacity: 0;
transform: translateX(60px);
transition: all 0.1s ease-in-out;
}
.chevron svg {
position: absolute;
top: 50%;
right: var(--cpd-space-1x);
transform: translateY(-50%);
width: 24px;
height: 24px;
color: var(--cpd-color-text-secondary);
}
.narrow {
min-width: initial;
max-width: 100%;
width: initial;
}
@@ -0,0 +1,106 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX } from "react";
import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useMockedViewModel } from "../../../../../core/viewmodel";
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
import {
ThreadSummaryView,
type ThreadMessagePreviewViewSnapshot,
type ThreadSummaryViewActions,
type ThreadSummaryViewSnapshot,
} from "./ThreadSummaryView";
type WrapperProps = Omit<ThreadSummaryViewSnapshot, "previewVm"> &
Partial<ThreadSummaryViewActions> & {
preview: ThreadMessagePreviewViewSnapshot;
};
const ThreadSummaryViewWrapperImpl = ({ onClick, preview, ...snapshotProps }: Readonly<WrapperProps>): JSX.Element => {
const previewVm = useMockedViewModel(preview, {});
const vm = useMockedViewModel(
{
...snapshotProps,
previewVm,
},
{
onClick: onClick ?? fn(),
},
);
return <ThreadSummaryView vm={vm} />;
};
const ThreadSummaryViewWrapper = withViewDocs(ThreadSummaryViewWrapperImpl, ThreadSummaryView);
const defaultPreview: ThreadMessagePreviewViewSnapshot = {
isVisible: true,
avatar: {
id: "@alice:example.org",
name: "Alice",
label: "User avatar",
},
showDisplayName: true,
senderName: "Alice",
previewContent: "Can you review the draft?",
previewTooltip: "Can you review the draft?",
};
const meta = {
title: "Timeline/Timeline Event//ThreadSummary",
component: ThreadSummaryViewWrapper,
tags: ["autodocs"],
args: {
isVisible: true,
replyCountLabel: "3 replies",
openThreadLabel: "Open thread",
notificationIndicator: undefined,
narrow: false,
preview: defaultPreview,
},
} satisfies Meta<typeof ThreadSummaryViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Narrow: Story = {
args: {
replyCountLabel: "3",
narrow: true,
preview: {
...defaultPreview,
showDisplayName: false,
},
},
};
export const WithNotification: Story = {
args: {
notificationIndicator: "critical",
},
};
export const DecryptionFailure: Story = {
args: {
preview: {
...defaultPreview,
previewContent: "Unable to decrypt message",
previewTooltip: "Unable to decrypt message",
},
},
};
export const Hidden: Story = {
args: {
isVisible: false,
},
};
@@ -0,0 +1,80 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { composeStories } from "@storybook/react-vite";
import { render, screen } from "@test-utils";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { MockViewModel } from "../../../../../core/viewmodel";
import {
ThreadSummaryView,
type ThreadMessagePreviewViewSnapshot,
type ThreadSummaryViewActions,
type ThreadSummaryViewModel,
type ThreadSummaryViewSnapshot,
} from "./ThreadSummaryView";
import * as stories from "./ThreadSummary.stories";
const { Default, Narrow, WithNotification, DecryptionFailure, Hidden } = composeStories(stories);
describe("ThreadSummaryView", () => {
it("renders the default thread summary", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
it("renders the narrow thread summary", () => {
const { container } = render(<Narrow />);
expect(container).toMatchSnapshot();
});
it("renders a notification indicator", () => {
const { container } = render(<WithNotification />);
expect(container).toMatchSnapshot();
});
it("renders a decryption failure preview", () => {
const { container } = render(<DecryptionFailure />);
expect(container).toMatchSnapshot();
});
it("does not render when hidden", () => {
render(<Hidden />);
expect(screen.queryByRole("button", { name: "Open thread" })).not.toBeInTheDocument();
});
it("invokes the click action", async () => {
const user = userEvent.setup();
const onClick = vi.fn();
const previewVm = new MockViewModel<ThreadMessagePreviewViewSnapshot>({
isVisible: true,
showDisplayName: false,
previewContent: "Latest reply",
});
const vm = new (class extends MockViewModel<ThreadSummaryViewSnapshot> implements ThreadSummaryViewActions {
public constructor() {
super({
isVisible: true,
replyCountLabel: "1 reply",
openThreadLabel: "Open thread",
narrow: false,
previewVm,
});
}
public onClick = onClick;
})() as ThreadSummaryViewModel;
render(<ThreadSummaryView vm={vm} />);
await user.click(screen.getByRole("button", { name: "Open thread" }));
expect(onClick).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,183 @@
/*
* Copyright 2026 Element Creations Ltd.
* Copyright 2024 New Vector Ltd.
* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type ComponentPropsWithoutRef, type JSX, type MouseEventHandler, type ReactNode } from "react";
import { Avatar, IndicatorIcon, Tooltip } from "@vector-im/compound-web";
import { ChevronRightIcon, ThreadsSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
import styles from "./ThreadSummary.module.css";
export type ThreadSummaryNotificationIndicator = ComponentPropsWithoutRef<typeof IndicatorIcon>["indicator"];
export interface ThreadMessagePreviewAvatar {
/**
* Optional class name supplied by the app for integration styling.
*/
className?: string;
/**
* Stable id used for avatar colour hashing.
*/
id: string;
/**
* Name used by the avatar fallback.
*/
name: string;
/**
* Optional avatar image URL.
*/
src?: string;
/**
* Accessible label for the avatar.
*/
label: string;
/**
* Optional tooltip/title text for the avatar image.
*/
title?: string;
}
export interface ThreadMessagePreviewViewSnapshot {
/**
* Controls whether the preview should render.
*/
isVisible: boolean;
/**
* Sender avatar data.
*/
avatar?: ThreadMessagePreviewAvatar;
/**
* Whether to render the sender display name.
*/
showDisplayName: boolean;
/**
* Sender display name.
*/
senderName?: string;
/**
* Rendered preview content.
*/
previewContent?: ReactNode;
/**
* Optional styled tooltip text for the preview content.
*/
previewTooltip?: string;
}
export type ThreadMessagePreviewViewModel = ViewModel<ThreadMessagePreviewViewSnapshot>;
export interface ThreadSummaryViewSnapshot {
/**
* Controls whether the summary should render.
*/
isVisible: boolean;
/**
* Text for the reply count section.
*/
replyCountLabel: string;
/**
* Accessible label for opening the thread.
*/
openThreadLabel: string;
/**
* Notification indicator shown on the thread icon.
*/
notificationIndicator?: ThreadSummaryNotificationIndicator;
/**
* Whether the summary is being rendered in the narrow timeline layout.
*/
narrow: boolean;
/**
* View model for the last-message preview.
*/
previewVm: ThreadMessagePreviewViewModel;
}
export interface ThreadSummaryViewActions {
/**
* Invoked when the user opens the thread.
*/
onClick?: MouseEventHandler<HTMLButtonElement>;
}
export type ThreadSummaryViewModel = ViewModel<ThreadSummaryViewSnapshot, ThreadSummaryViewActions>;
type ThreadSummaryViewProps = Omit<ComponentPropsWithoutRef<"button">, "aria-label" | "onClick"> & {
/**
* The view model for the thread summary.
*/
vm: ThreadSummaryViewModel;
};
interface ThreadMessagePreviewViewProps {
/**
* The view model for the thread message preview.
*/
vm: ThreadMessagePreviewViewModel;
}
export function ThreadMessagePreviewView({ vm }: Readonly<ThreadMessagePreviewViewProps>): JSX.Element {
const { isVisible, avatar, showDisplayName, senderName, previewContent, previewTooltip } = useViewModel(vm);
if (!isVisible || !previewContent) {
return <></>;
}
const content = <span className={styles.content}>{previewContent}</span>;
const avatarClassName = avatar?.className ? `${styles.avatar} ${avatar.className}` : styles.avatar;
return (
<>
{avatar && (
<Avatar
id={avatar.id}
name={avatar.name}
src={avatar.src}
title={avatar.title}
aria-label={avatar.label}
type="round"
size="24px"
className={avatarClassName}
/>
)}
{showDisplayName && senderName && <div className={styles.sender}>{senderName}</div>}
{previewTooltip ? <Tooltip description={previewTooltip}>{content}</Tooltip> : content}
</>
);
}
export function ThreadSummaryView({
vm,
className,
type = "button",
...props
}: Readonly<ThreadSummaryViewProps>): JSX.Element {
const { isVisible, replyCountLabel, openThreadLabel, notificationIndicator, narrow, previewVm } = useViewModel(vm);
if (!isVisible) {
return <></>;
}
const buttonClassName = [styles.threadSummary, className, narrow ? styles.narrow : undefined]
.filter(Boolean)
.join(" ");
return (
<button {...props} type={type} className={buttonClassName} onClick={vm.onClick} aria-label={openThreadLabel}>
<IndicatorIcon size="24px" indicator={notificationIndicator} className={styles.threadIcon}>
<ThreadsSolidIcon />
</IndicatorIcon>
<span className={styles.repliesAmount}>{replyCountLabel}</span>
<ThreadMessagePreviewView vm={previewVm} />
<div className={styles.chevron}>
<ChevronRightIcon />
</div>
</button>
);
}
@@ -0,0 +1,265 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ThreadSummaryView > renders a decryption failure preview 1`] = `
<div>
<button
aria-label="Open thread"
class="ThreadSummary-module_threadSummary"
type="button"
>
<div
class="_indicator-icon_147l5_17 ThreadSummary-module_threadIcon"
style="--cpd-icon-button-size: 24px;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 3h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6l-2.293 2.293c-.63.63-1.707.184-1.707-.707V5a2 2 0 0 1 2-2m3 7h10q.424 0 .712-.287A.97.97 0 0 0 18 9a.97.97 0 0 0-.288-.713A.97.97 0 0 0 17 8H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 9q0 .424.287.713Q6.576 10 7 10m0 4h6q.424 0 .713-.287A.97.97 0 0 0 14 13a.97.97 0 0 0-.287-.713A.97.97 0 0 0 13 12H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 13q0 .424.287.713Q6.576 14 7 14"
/>
</svg>
</div>
<span
class="ThreadSummary-module_repliesAmount"
>
3 replies
</span>
<span
aria-label="User avatar"
class="_avatar_va14e_8 ThreadSummary-module_avatar _avatar-imageless_va14e_55"
data-color="3"
data-type="round"
role="img"
style="--cpd-avatar-size: 24px;"
>
A
</span>
<div
class="ThreadSummary-module_sender"
>
Alice
</div>
<span
class="ThreadSummary-module_content"
>
Unable to decrypt message
</span>
<div
class="ThreadSummary-module_chevron"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</div>
</button>
</div>
`;
exports[`ThreadSummaryView > renders a notification indicator 1`] = `
<div>
<button
aria-label="Open thread"
class="ThreadSummary-module_threadSummary"
type="button"
>
<div
class="_indicator-icon_147l5_17 ThreadSummary-module_threadIcon"
data-indicator="critical"
style="--cpd-icon-button-size: 24px;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 3h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6l-2.293 2.293c-.63.63-1.707.184-1.707-.707V5a2 2 0 0 1 2-2m3 7h10q.424 0 .712-.287A.97.97 0 0 0 18 9a.97.97 0 0 0-.288-.713A.97.97 0 0 0 17 8H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 9q0 .424.287.713Q6.576 10 7 10m0 4h6q.424 0 .713-.287A.97.97 0 0 0 14 13a.97.97 0 0 0-.287-.713A.97.97 0 0 0 13 12H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 13q0 .424.287.713Q6.576 14 7 14"
/>
</svg>
</div>
<span
class="ThreadSummary-module_repliesAmount"
>
3 replies
</span>
<span
aria-label="User avatar"
class="_avatar_va14e_8 ThreadSummary-module_avatar _avatar-imageless_va14e_55"
data-color="3"
data-type="round"
role="img"
style="--cpd-avatar-size: 24px;"
>
A
</span>
<div
class="ThreadSummary-module_sender"
>
Alice
</div>
<span
class="ThreadSummary-module_content"
>
Can you review the draft?
</span>
<div
class="ThreadSummary-module_chevron"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</div>
</button>
</div>
`;
exports[`ThreadSummaryView > renders the default thread summary 1`] = `
<div>
<button
aria-label="Open thread"
class="ThreadSummary-module_threadSummary"
type="button"
>
<div
class="_indicator-icon_147l5_17 ThreadSummary-module_threadIcon"
style="--cpd-icon-button-size: 24px;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 3h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6l-2.293 2.293c-.63.63-1.707.184-1.707-.707V5a2 2 0 0 1 2-2m3 7h10q.424 0 .712-.287A.97.97 0 0 0 18 9a.97.97 0 0 0-.288-.713A.97.97 0 0 0 17 8H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 9q0 .424.287.713Q6.576 10 7 10m0 4h6q.424 0 .713-.287A.97.97 0 0 0 14 13a.97.97 0 0 0-.287-.713A.97.97 0 0 0 13 12H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 13q0 .424.287.713Q6.576 14 7 14"
/>
</svg>
</div>
<span
class="ThreadSummary-module_repliesAmount"
>
3 replies
</span>
<span
aria-label="User avatar"
class="_avatar_va14e_8 ThreadSummary-module_avatar _avatar-imageless_va14e_55"
data-color="3"
data-type="round"
role="img"
style="--cpd-avatar-size: 24px;"
>
A
</span>
<div
class="ThreadSummary-module_sender"
>
Alice
</div>
<span
class="ThreadSummary-module_content"
>
Can you review the draft?
</span>
<div
class="ThreadSummary-module_chevron"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</div>
</button>
</div>
`;
exports[`ThreadSummaryView > renders the narrow thread summary 1`] = `
<div>
<button
aria-label="Open thread"
class="ThreadSummary-module_threadSummary ThreadSummary-module_narrow"
type="button"
>
<div
class="_indicator-icon_147l5_17 ThreadSummary-module_threadIcon"
style="--cpd-icon-button-size: 24px;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M4 3h16a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H6l-2.293 2.293c-.63.63-1.707.184-1.707-.707V5a2 2 0 0 1 2-2m3 7h10q.424 0 .712-.287A.97.97 0 0 0 18 9a.97.97 0 0 0-.288-.713A.97.97 0 0 0 17 8H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 9q0 .424.287.713Q6.576 10 7 10m0 4h6q.424 0 .713-.287A.97.97 0 0 0 14 13a.97.97 0 0 0-.287-.713A.97.97 0 0 0 13 12H7a.97.97 0 0 0-.713.287A.97.97 0 0 0 6 13q0 .424.287.713Q6.576 14 7 14"
/>
</svg>
</div>
<span
class="ThreadSummary-module_repliesAmount"
>
3
</span>
<span
aria-label="User avatar"
class="_avatar_va14e_8 ThreadSummary-module_avatar _avatar-imageless_va14e_55"
data-color="3"
data-type="round"
role="img"
style="--cpd-avatar-size: 24px;"
>
A
</span>
<span
class="ThreadSummary-module_content"
>
Can you review the draft?
</span>
<div
class="ThreadSummary-module_chevron"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.95.95 0 0 1-.275-.7q0-.425.275-.7l3.9-3.9-3.9-3.9a.95.95 0 0 1-.275-.7q0-.425.275-.7a.95.95 0 0 1 .7-.275q.425 0 .7.275l4.6 4.6q.15.15.213.325.062.175.062.375t-.062.375a.9.9 0 0 1-.213.325l-4.6 4.6a.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
/>
</svg>
</div>
</button>
</div>
`;
@@ -0,0 +1,18 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
export {
ThreadMessagePreviewView,
ThreadSummaryView,
type ThreadMessagePreviewAvatar,
type ThreadMessagePreviewViewModel,
type ThreadMessagePreviewViewSnapshot,
type ThreadSummaryNotificationIndicator,
type ThreadSummaryViewActions,
type ThreadSummaryViewModel,
type ThreadSummaryViewSnapshot,
} from "./ThreadSummaryView";