Tidy up snapshot, actions and vm names according to MVVM doc (#32819)

* chore: rename snapshot, actions and vm according to MVVM doc

* doc: add naming conventions

* chore: fix UrlGroupView naming, folders and children

* chore: remove `Example` column

* refactor: rename `UrlPreviewGroupViewPreview` into `UrlPreview`
This commit is contained in:
Florian Duros
2026-03-17 15:20:52 +01:00
committed by GitHub
parent 6339bcda15
commit 136bb78c15
54 changed files with 194 additions and 162 deletions
@@ -10,7 +10,7 @@ import React, { type JSX, createRef, type SyntheticEvent, type MouseEvent, useCa
import { MsgType } from "matrix-js-sdk/src/matrix"; import { MsgType } from "matrix-js-sdk/src/matrix";
import { import {
UrlPreviewGroupView, UrlPreviewGroupView,
type UrlPreviewViewSnapshotPreview, type UrlPreview,
useCreateAutoDisposedViewModel, useCreateAutoDisposedViewModel,
EventContentBodyView, EventContentBodyView,
LINKIFIED_DATA_ATTRIBUTE, LINKIFIED_DATA_ATTRIBUTE,
@@ -35,14 +35,14 @@ import AccessibleButton from "../elements/AccessibleButton";
import { getParentEventId } from "../../../utils/Reply"; import { getParentEventId } from "../../../utils/Reply";
import { EditWysiwygComposer } from "../rooms/wysiwyg_composer"; import { EditWysiwygComposer } from "../rooms/wysiwyg_composer";
import { type IEventTileOps } from "../rooms/EventTile"; import { type IEventTileOps } from "../rooms/EventTile";
import { UrlPreviewViewModel } from "../../../viewmodels/message-body/UrlPreviewViewModel"; import { UrlPreviewGroupViewModel } from "../../../viewmodels/message-body/UrlPreviewGroupViewModel.ts";
import { useMediaVisible } from "../../../hooks/useMediaVisible.ts"; import { useMediaVisible } from "../../../hooks/useMediaVisible.ts";
import ImageView from "../elements/ImageView.tsx"; import ImageView from "../elements/ImageView.tsx";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx";
const logger = rootLogger.getChild("TextualBody"); const logger = rootLogger.getChild("TextualBody");
type Props = IBodyProps & { urlPreviewViewModel: UrlPreviewViewModel }; type Props = IBodyProps & { urlPreviewViewModel: UrlPreviewGroupViewModel };
class InnerTextualBody extends React.Component<Props> { class InnerTextualBody extends React.Component<Props> {
private readonly contentRef = createRef<HTMLDivElement>(); private readonly contentRef = createRef<HTMLDivElement>();
@@ -390,7 +390,7 @@ export default function TextualBody(props: IBodyProps): React.ReactElement {
const [mediaVisible] = useMediaVisible(props.mxEvent); const [mediaVisible] = useMediaVisible(props.mxEvent);
const client = useMatrixClientContext(); const client = useMatrixClientContext();
const onUrlPreviewImageClicked = useCallback((preview: UrlPreviewViewSnapshotPreview): void => { const onUrlPreviewImageClicked = useCallback((preview: UrlPreview): void => {
if (!preview.image?.imageFull) { if (!preview.image?.imageFull) {
// Should never get this far, but doesn't hurt to check. // Should never get this far, but doesn't hurt to check.
return; return;
@@ -408,7 +408,7 @@ export default function TextualBody(props: IBodyProps): React.ReactElement {
const vm = useCreateAutoDisposedViewModel( const vm = useCreateAutoDisposedViewModel(
() => () =>
new UrlPreviewViewModel({ new UrlPreviewGroupViewModel({
client, client,
mxEvent: props.mxEvent, mxEvent: props.mxEvent,
mediaVisible: mediaVisible, mediaVisible: mediaVisible,
@@ -18,7 +18,7 @@ import { RoomAvatarView } from "../../avatars/RoomAvatarView";
import { getKeyBindingsManager } from "../../../../KeyBindingsManager"; import { getKeyBindingsManager } from "../../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts"; import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
import { Landmark, LandmarkNavigation } from "../../../../accessibility/LandmarkNavigation"; import { Landmark, LandmarkNavigation } from "../../../../accessibility/LandmarkNavigation";
import { RoomListViewViewModel } from "../../../../viewmodels/room-list/RoomListViewViewModel"; import { RoomListViewModel } from "../../../../viewmodels/room-list/RoomListViewModel";
/** /**
* RoomListView component using shared components with proper MVVM pattern. * RoomListView component using shared components with proper MVVM pattern.
@@ -27,7 +27,7 @@ export function RoomListView(): JSX.Element {
const matrixClient = useMatrixClientContext(); const matrixClient = useMatrixClientContext();
// Create and auto-dispose ViewModel instance // Create and auto-dispose ViewModel instance
const vm = useCreateAutoDisposedViewModel(() => new RoomListViewViewModel({ client: matrixClient })); const vm = useCreateAutoDisposedViewModel(() => new RoomListViewModel({ client: matrixClient }));
// Render avatar for each room - memoized to prevent re-renders // Render avatar for each room - memoized to prevent re-renders
const renderAvatar = useCallback((room: SharedRoom): ReactNode => { const renderAvatar = useCallback((room: SharedRoom): ReactNode => {
@@ -9,7 +9,7 @@ import {
BaseViewModel, BaseViewModel,
type UrlPreviewGroupViewSnapshot, type UrlPreviewGroupViewSnapshot,
type UrlPreviewGroupViewActions, type UrlPreviewGroupViewActions,
type UrlPreviewViewSnapshotPreview, type UrlPreview,
} from "@element-hq/web-shared-components"; } from "@element-hq/web-shared-components";
import { logger as rootLogger } from "matrix-js-sdk/src/logger"; import { logger as rootLogger } from "matrix-js-sdk/src/logger";
import { type IPreviewUrlResponse, type MatrixClient, MatrixError, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { type IPreviewUrlResponse, type MatrixClient, MatrixError, type MatrixEvent } from "matrix-js-sdk/src/matrix";
@@ -21,14 +21,14 @@ import PlatformPeg from "../../PlatformPeg";
import { thumbHeight } from "../../ImageUtils"; import { thumbHeight } from "../../ImageUtils";
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
const logger = rootLogger.getChild("UrlPreviewViewModel"); const logger = rootLogger.getChild("UrlPreviewGroupViewModel");
export interface UrlPreviewViewModelProps { export interface UrlPreviewGroupViewModelProps {
client: MatrixClient; client: MatrixClient;
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
mediaVisible: boolean; mediaVisible: boolean;
visible: boolean; visible: boolean;
onImageClicked: (preview: UrlPreviewViewSnapshotPreview) => void; onImageClicked: (preview: UrlPreview) => void;
} }
export const MAX_PREVIEWS_WHEN_LIMITED = 2; export const MAX_PREVIEWS_WHEN_LIMITED = 2;
@@ -57,8 +57,8 @@ export enum PreviewVisibility {
/** /**
* ViewModel for fetching and rendering URL previews for an individual event. * ViewModel for fetching and rendering URL previews for an individual event.
*/ */
export class UrlPreviewViewModel export class UrlPreviewGroupViewModel
extends BaseViewModel<UrlPreviewGroupViewSnapshot, UrlPreviewViewModelProps> extends BaseViewModel<UrlPreviewGroupViewSnapshot, UrlPreviewGroupViewModelProps>
implements UrlPreviewGroupViewActions implements UrlPreviewGroupViewActions
{ {
/** /**
@@ -89,7 +89,7 @@ export class UrlPreviewViewModel
private static getBaseMetadataFromResponse( private static getBaseMetadataFromResponse(
response: IPreviewUrlResponse, response: IPreviewUrlResponse,
link: string, link: string,
): Pick<UrlPreviewViewSnapshotPreview, "title" | "description" | "siteName"> { ): Pick<UrlPreview, "title" | "description" | "siteName"> {
let title = let title =
typeof response["og:title"] === "string" && response["og:title"].trim() typeof response["og:title"] === "string" && response["og:title"].trim()
? response["og:title"].trim() ? response["og:title"].trim()
@@ -215,14 +215,14 @@ export class UrlPreviewViewModel
/** /**
* A cache containing all previously calculated previews. * A cache containing all previously calculated previews.
*/ */
private readonly previewCache = new Map<string, UrlPreviewViewSnapshotPreview>(); private readonly previewCache = new Map<string, UrlPreview>();
/** /**
* Called when the user clicks on the preview thumbnail. * Called when the user clicks on the preview thumbnail.
*/ */
public readonly onImageClick: (preview: UrlPreviewViewSnapshotPreview) => void; public readonly onImageClick: (preview: UrlPreview) => void;
public constructor(props: UrlPreviewViewModelProps) { public constructor(props: UrlPreviewGroupViewModelProps) {
const storageKey = `hide_preview_${props.mxEvent.getId()}`; const storageKey = `hide_preview_${props.mxEvent.getId()}`;
super(props, { super(props, {
previews: [], previews: [],
@@ -256,7 +256,7 @@ export class UrlPreviewViewModel
* @returns A Promise that returns the snapshot needed to render the preview, or null * @returns A Promise that returns the snapshot needed to render the preview, or null
* if the resource could not be previewed. * if the resource could not be previewed.
*/ */
private async fetchPreview(link: string): Promise<UrlPreviewViewSnapshotPreview | null> { private async fetchPreview(link: string): Promise<UrlPreview | null> {
const cached = this.previewCache.get(link); const cached = this.previewCache.get(link);
if (cached) { if (cached) {
return cached; return cached;
@@ -275,18 +275,18 @@ export class UrlPreviewViewModel
return null; return null;
} }
const { title, description, siteName } = UrlPreviewViewModel.getBaseMetadataFromResponse(preview, link); const { title, description, siteName } = UrlPreviewGroupViewModel.getBaseMetadataFromResponse(preview, link);
const hasImage = preview["og:image"] && typeof preview?.["og:image"] === "string"; const hasImage = preview["og:image"] && typeof preview?.["og:image"] === "string";
// Ensure we have something relevant to render. // Ensure we have something relevant to render.
// The title must not just be the link, or we must have an image. // The title must not just be the link, or we must have an image.
if (title === link && !hasImage) { if (title === link && !hasImage) {
return null; return null;
} }
let image: UrlPreviewViewSnapshotPreview["image"]; let image: UrlPreview["image"];
if (typeof preview["og:image"] === "string" && this.visibility > PreviewVisibility.MediaHidden) { if (typeof preview["og:image"] === "string" && this.visibility > PreviewVisibility.MediaHidden) {
const media = mediaFromMxc(preview["og:image"], this.client); const media = mediaFromMxc(preview["og:image"], this.client);
const declaredHeight = UrlPreviewViewModel.getNumberFromOpenGraph(preview["og:image:height"]); const declaredHeight = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["og:image:height"]);
const declaredWidth = UrlPreviewViewModel.getNumberFromOpenGraph(preview["og:image:width"]); const declaredWidth = UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["og:image:width"]);
const width = Math.min(declaredWidth ?? PREVIEW_WIDTH, PREVIEW_WIDTH); const width = Math.min(declaredWidth ?? PREVIEW_WIDTH, PREVIEW_WIDTH);
const height = thumbHeight(width, declaredHeight, PREVIEW_WIDTH, PREVIEW_WIDTH) ?? PREVIEW_WIDTH; const height = thumbHeight(width, declaredHeight, PREVIEW_WIDTH, PREVIEW_WIDTH) ?? PREVIEW_WIDTH;
const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH, PREVIEW_HEIGHT, "scale"); const thumb = media.getThumbnailOfSourceHttp(PREVIEW_WIDTH, PREVIEW_HEIGHT, "scale");
@@ -297,7 +297,7 @@ export class UrlPreviewViewModel
imageFull: media.srcHttp ?? thumb, imageFull: media.srcHttp ?? thumb,
width, width,
height, height,
fileSize: UrlPreviewViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]), fileSize: UrlPreviewGroupViewModel.getNumberFromOpenGraph(preview["matrix:image:size"]),
}; };
} }
} }
@@ -309,7 +309,7 @@ export class UrlPreviewViewModel
siteName, siteName,
showTooltipOnLink: link !== title && PlatformPeg.get()?.needsUrlTooltips(), showTooltipOnLink: link !== title && PlatformPeg.get()?.needsUrlTooltips(),
image, image,
} satisfies UrlPreviewViewSnapshotPreview; } satisfies UrlPreview;
this.previewCache.set(link, result); this.previewCache.set(link, result);
return result; return result;
} }
@@ -356,7 +356,7 @@ export class UrlPreviewViewModel
* @param eventElement * @param eventElement
*/ */
public async updateEventElement(eventElement: HTMLDivElement): Promise<void> { public async updateEventElement(eventElement: HTMLDivElement): Promise<void> {
const newLinks = UrlPreviewViewModel.findLinks([eventElement]); const newLinks = UrlPreviewGroupViewModel.findLinks([eventElement]);
// Only recalculate if the set of links has changed. // Only recalculate if the set of links has changed.
if (newLinks.some((x) => !this.links.includes(x)) || this.links.some((x) => !newLinks.includes(x))) { if (newLinks.some((x) => !this.links.includes(x)) || this.links.some((x) => !newLinks.includes(x))) {
this.links = newLinks; this.links = newLinks;
@@ -11,7 +11,7 @@ import { type Room, type MatrixClient } from "matrix-js-sdk/src/matrix";
import { type IWidget, MatrixCapabilities } from "matrix-widget-api"; import { type IWidget, MatrixCapabilities } from "matrix-widget-api";
import { import {
BaseViewModel, BaseViewModel,
type WidgetContextMenuSnapshot, type WidgetContextMenuViewSnapshot,
WidgetContextMenuView, WidgetContextMenuView,
type WidgetContextMenuViewModel as WidgetContextMenuViewModelInterface, type WidgetContextMenuViewModel as WidgetContextMenuViewModelInterface,
} from "@element-hq/web-shared-components"; } from "@element-hq/web-shared-components";
@@ -56,7 +56,7 @@ const checkRevokeButtonState = (
}; };
export class WidgetContextMenuViewModel export class WidgetContextMenuViewModel
extends BaseViewModel<WidgetContextMenuSnapshot, WidgetContextMenuViewModelProps> extends BaseViewModel<WidgetContextMenuViewSnapshot, WidgetContextMenuViewModelProps>
implements WidgetContextMenuViewModelInterface implements WidgetContextMenuViewModelInterface
{ {
private _app: IWidget; private _app: IWidget;
@@ -96,7 +96,7 @@ export class WidgetContextMenuViewModel
menuDisplayed: boolean, menuDisplayed: boolean,
trigger: ReactNode, trigger: ReactNode,
onDeleteClick?: () => void, onDeleteClick?: () => void,
): WidgetContextMenuSnapshot => { ): WidgetContextMenuViewSnapshot => {
const showStreamAudioStreamButton = !!getConfigLivestreamUrl() && WidgetType.JITSI.matches(app.type); const showStreamAudioStreamButton = !!getConfigLivestreamUrl() && WidgetType.JITSI.matches(app.type);
const canModify = userWidget || WidgetUtils.canUserModifyWidgets(cli, room?.roomId); const canModify = userWidget || WidgetUtils.canUserModifyWidgets(cli, room?.roomId);
const widgetMessaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(app)); const widgetMessaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(app));
@@ -8,8 +8,8 @@ Please see LICENSE files in the repository root for full details.
import { import {
BaseViewModel, BaseViewModel,
RoomNotifState, RoomNotifState,
type RoomListItemSnapshot, type RoomListItemViewSnapshot,
type RoomListItemActions, type RoomListItemViewActions,
} from "@element-hq/web-shared-components"; } from "@element-hq/web-shared-components";
import { RoomEvent } from "matrix-js-sdk/src/matrix"; import { RoomEvent } from "matrix-js-sdk/src/matrix";
import { CallType } from "matrix-js-sdk/src/webrtc/call"; import { CallType } from "matrix-js-sdk/src/webrtc/call";
@@ -46,11 +46,11 @@ interface RoomItemProps {
/** /**
* View model for an individual room list item. * View model for an individual room list item.
* Manages per-room subscriptions and updates only when this specific room's data changes. * Manages per-room subscriptions and updates only when this specific room's data changes.
* Implements RoomListItemActions to provide interaction callbacks. * Implements RoomListItemViewActions to provide interaction callbacks.
*/ */
export class RoomListItemViewModel export class RoomListItemViewModel
extends BaseViewModel<RoomListItemSnapshot, RoomItemProps> extends BaseViewModel<RoomListItemViewSnapshot, RoomItemProps>
implements RoomListItemActions implements RoomListItemViewActions
{ {
private notifState: RoomNotificationState; private notifState: RoomNotificationState;
/** /**
@@ -205,7 +205,7 @@ export class RoomListItemViewModel
room: Room, room: Room,
client: MatrixClient, client: MatrixClient,
notifState: RoomNotificationState, notifState: RoomNotificationState,
): RoomListItemSnapshot { ): RoomListItemViewSnapshot {
// Get room tags for menu state // Get room tags for menu state
const roomTags = room.tags; const roomTags = room.tags;
const isDm = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId)); const isDm = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId));
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
import { import {
BaseViewModel, BaseViewModel,
type RoomListSnapshot, type RoomListViewSnapshot,
type FilterId, type FilterId,
type RoomListViewActions, type RoomListViewActions,
type RoomListViewState, type RoomListViewState,
@@ -27,7 +27,7 @@ import { SdkContextClass } from "../../contexts/SDKContext";
import { hasCreateRoomRights } from "./utils"; import { hasCreateRoomRights } from "./utils";
import { keepIfSame } from "../../utils/keepIfSame"; import { keepIfSame } from "../../utils/keepIfSame";
interface RoomListViewViewModelProps { interface RoomListViewModelProps {
client: MatrixClient; client: MatrixClient;
} }
@@ -41,8 +41,8 @@ const filterKeyToIdMap: Map<FilterKey, FilterId> = new Map([
[FilterKey.LowPriorityFilter, "low_priority"], [FilterKey.LowPriorityFilter, "low_priority"],
]); ]);
export class RoomListViewViewModel export class RoomListViewModel
extends BaseViewModel<RoomListSnapshot, RoomListViewViewModelProps> extends BaseViewModel<RoomListViewSnapshot, RoomListViewModelProps>
implements RoomListViewActions implements RoomListViewActions
{ {
// State tracking // State tracking
@@ -54,7 +54,7 @@ export class RoomListViewViewModel
private roomItemViewModels = new Map<string, RoomListItemViewModel>(); private roomItemViewModels = new Map<string, RoomListItemViewModel>();
private roomsMap = new Map<string, Room>(); private roomsMap = new Map<string, Room>();
public constructor(props: RoomListViewViewModelProps) { public constructor(props: RoomListViewModelProps) {
const activeSpace = SpaceStore.instance.activeSpaceRoom; const activeSpace = SpaceStore.instance.activeSpaceRoom;
// Get initial rooms // Get initial rooms
@@ -9,8 +9,8 @@ import { expect } from "@jest/globals";
import type { MockedObject } from "jest-mock"; import type { MockedObject } from "jest-mock";
import type { MatrixClient, IPreviewUrlResponse } from "matrix-js-sdk/src/matrix"; import type { MatrixClient, IPreviewUrlResponse } from "matrix-js-sdk/src/matrix";
import { UrlPreviewViewModel } from "../../../src/viewmodels/message-body/UrlPreviewViewModel"; import { UrlPreviewGroupViewModel } from "../../../src/viewmodels/message-body/UrlPreviewGroupViewModel";
import type { UrlPreviewViewSnapshotPreview } from "@element-hq/web-shared-components"; import type { UrlPreview } from "@element-hq/web-shared-components";
import { getMockClientWithEventEmitter, mkEvent } from "../../test-utils"; import { getMockClientWithEventEmitter, mkEvent } from "../../test-utils";
const IMAGE_MXC = "mxc://example.org/abc"; const IMAGE_MXC = "mxc://example.org/abc";
@@ -23,16 +23,16 @@ const BASIC_PREVIEW_OGDATA = {
}; };
function getViewModel({ mediaVisible, visible } = { mediaVisible: true, visible: true }): { function getViewModel({ mediaVisible, visible } = { mediaVisible: true, visible: true }): {
vm: UrlPreviewViewModel; vm: UrlPreviewGroupViewModel;
client: MockedObject<MatrixClient>; client: MockedObject<MatrixClient>;
onImageClicked: jest.Mock<void, [UrlPreviewViewSnapshotPreview]>; onImageClicked: jest.Mock<void, [UrlPreview]>;
} { } {
const client = getMockClientWithEventEmitter({ const client = getMockClientWithEventEmitter({
getUrlPreview: jest.fn(), getUrlPreview: jest.fn(),
mxcUrlToHttp: jest.fn(), mxcUrlToHttp: jest.fn(),
}); });
const onImageClicked = jest.fn<void, [UrlPreviewViewSnapshotPreview]>(); const onImageClicked = jest.fn<void, [UrlPreview]>();
const vm = new UrlPreviewViewModel({ const vm = new UrlPreviewGroupViewModel({
client, client,
mediaVisible, mediaVisible,
visible, visible,
@@ -48,7 +48,7 @@ function getViewModel({ mediaVisible, visible } = { mediaVisible: true, visible:
return { vm, client, onImageClicked }; return { vm, client, onImageClicked };
} }
describe("UrlPreviewViewModel", () => { describe("UrlPreviewGroupViewModel", () => {
it("should return no previews by default", () => { it("should return no previews by default", () => {
expect(getViewModel().vm.getSnapshot()).toMatchSnapshot(); expect(getViewModel().vm.getSnapshot()).toMatchSnapshot();
}); });
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing // Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`UrlPreviewViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:description': 'A description',\\n 'og:title': ''\\n} 1`] = ` exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:description': 'A description',\\n 'og:title': ''\\n} 1`] = `
{ {
"description": undefined, "description": undefined,
"image": undefined, "image": undefined,
@@ -11,7 +11,7 @@ exports[`UrlPreviewViewModel handles different kinds of opengraph responses {\\n
} }
`; `;
exports[`UrlPreviewViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:site_name': 'Site name',\\n 'og:title': ''\\n} 1`] = ` exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:site_name': 'Site name',\\n 'og:title': ''\\n} 1`] = `
{ {
"description": undefined, "description": undefined,
"image": undefined, "image": undefined,
@@ -22,7 +22,7 @@ exports[`UrlPreviewViewModel handles different kinds of opengraph responses {\\n
} }
`; `;
exports[`UrlPreviewViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Basic title'\\n} 1`] = ` exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Basic title'\\n} 1`] = `
{ {
"description": undefined, "description": undefined,
"image": undefined, "image": undefined,
@@ -33,7 +33,7 @@ exports[`UrlPreviewViewModel handles different kinds of opengraph responses {\\n
} }
`; `;
exports[`UrlPreviewViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Cool blog',\\n 'og:site_name': 'Cool site'\\n} 1`] = ` exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Cool blog',\\n 'og:site_name': 'Cool site'\\n} 1`] = `
{ {
"description": undefined, "description": undefined,
"image": undefined, "image": undefined,
@@ -44,7 +44,7 @@ exports[`UrlPreviewViewModel handles different kinds of opengraph responses {\\n
} }
`; `;
exports[`UrlPreviewViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Media test',\\n 'og:image:height': '500',\\n 'og:image:width': 500,\\n 'matrix:image:size': 1024,\\n 'og:image': 'mxc://example.org/abc'\\n} 1`] = ` exports[`UrlPreviewGroupViewModel handles different kinds of opengraph responses {\\n 'og:url': 'https://example.org',\\n 'og:type': 'document',\\n 'og:title': 'Media test',\\n 'og:image:height': '500',\\n 'og:image:width': 500,\\n 'matrix:image:size': 1024,\\n 'og:image': 'mxc://example.org/abc'\\n} 1`] = `
{ {
"description": undefined, "description": undefined,
"image": { "image": {
@@ -61,7 +61,7 @@ exports[`UrlPreviewViewModel handles different kinds of opengraph responses {\\n
} }
`; `;
exports[`UrlPreviewViewModel should deduplicate multiple versions of the same URL 1`] = ` exports[`UrlPreviewGroupViewModel should deduplicate multiple versions of the same URL 1`] = `
{ {
"compactLayout": false, "compactLayout": false,
"overPreviewLimit": false, "overPreviewLimit": false,
@@ -80,7 +80,7 @@ exports[`UrlPreviewViewModel should deduplicate multiple versions of the same UR
} }
`; `;
exports[`UrlPreviewViewModel should handle being hidden and shown by the user 1`] = ` exports[`UrlPreviewGroupViewModel should handle being hidden and shown by the user 1`] = `
{ {
"compactLayout": false, "compactLayout": false,
"overPreviewLimit": false, "overPreviewLimit": false,
@@ -90,7 +90,7 @@ exports[`UrlPreviewViewModel should handle being hidden and shown by the user 1`
} }
`; `;
exports[`UrlPreviewViewModel should handle being hidden and shown by the user 2`] = ` exports[`UrlPreviewGroupViewModel should handle being hidden and shown by the user 2`] = `
{ {
"compactLayout": false, "compactLayout": false,
"overPreviewLimit": false, "overPreviewLimit": false,
@@ -109,7 +109,7 @@ exports[`UrlPreviewViewModel should handle being hidden and shown by the user 2`
} }
`; `;
exports[`UrlPreviewViewModel should hide preview when invisible 1`] = ` exports[`UrlPreviewGroupViewModel should hide preview when invisible 1`] = `
{ {
"compactLayout": false, "compactLayout": false,
"overPreviewLimit": false, "overPreviewLimit": false,
@@ -119,7 +119,7 @@ exports[`UrlPreviewViewModel should hide preview when invisible 1`] = `
} }
`; `;
exports[`UrlPreviewViewModel should ignore failed previews 1`] = ` exports[`UrlPreviewGroupViewModel should ignore failed previews 1`] = `
{ {
"compactLayout": false, "compactLayout": false,
"overPreviewLimit": false, "overPreviewLimit": false,
@@ -129,7 +129,7 @@ exports[`UrlPreviewViewModel should ignore failed previews 1`] = `
} }
`; `;
exports[`UrlPreviewViewModel should ignore media when mediaVisible is false 1`] = ` exports[`UrlPreviewGroupViewModel should ignore media when mediaVisible is false 1`] = `
{ {
"compactLayout": false, "compactLayout": false,
"overPreviewLimit": false, "overPreviewLimit": false,
@@ -148,7 +148,7 @@ exports[`UrlPreviewViewModel should ignore media when mediaVisible is false 1`]
} }
`; `;
exports[`UrlPreviewViewModel should preview a URL with media 1`] = ` exports[`UrlPreviewGroupViewModel should preview a URL with media 1`] = `
{ {
"compactLayout": false, "compactLayout": false,
"overPreviewLimit": false, "overPreviewLimit": false,
@@ -173,7 +173,7 @@ exports[`UrlPreviewViewModel should preview a URL with media 1`] = `
} }
`; `;
exports[`UrlPreviewViewModel should preview a single valid URL 1`] = ` exports[`UrlPreviewGroupViewModel should preview a single valid URL 1`] = `
{ {
"compactLayout": false, "compactLayout": false,
"overPreviewLimit": false, "overPreviewLimit": false,
@@ -192,7 +192,7 @@ exports[`UrlPreviewViewModel should preview a single valid URL 1`] = `
} }
`; `;
exports[`UrlPreviewViewModel should return no previews by default 1`] = ` exports[`UrlPreviewGroupViewModel should return no previews by default 1`] = `
{ {
"compactLayout": false, "compactLayout": false,
"overPreviewLimit": false, "overPreviewLimit": false,
@@ -16,7 +16,7 @@ import dispatcher from "../../../src/dispatcher/dispatcher";
import { Action } from "../../../src/dispatcher/actions"; import { Action } from "../../../src/dispatcher/actions";
import { SdkContextClass } from "../../../src/contexts/SDKContext"; import { SdkContextClass } from "../../../src/contexts/SDKContext";
import DMRoomMap from "../../../src/utils/DMRoomMap"; import DMRoomMap from "../../../src/utils/DMRoomMap";
import { RoomListViewViewModel } from "../../../src/viewmodels/room-list/RoomListViewViewModel"; import { RoomListViewModel } from "../../../src/viewmodels/room-list/RoomListViewModel";
import { hasCreateRoomRights } from "../../../src/viewmodels/room-list/utils"; import { hasCreateRoomRights } from "../../../src/viewmodels/room-list/utils";
jest.mock("../../../src/viewmodels/room-list/utils", () => ({ jest.mock("../../../src/viewmodels/room-list/utils", () => ({
@@ -25,12 +25,12 @@ jest.mock("../../../src/viewmodels/room-list/utils", () => ({
hasAccessToNotificationMenu: jest.fn().mockReturnValue(true), hasAccessToNotificationMenu: jest.fn().mockReturnValue(true),
})); }));
describe("RoomListViewViewModel", () => { describe("RoomListViewModel", () => {
let matrixClient: MatrixClient; let matrixClient: MatrixClient;
let room1: Room; let room1: Room;
let room2: Room; let room2: Room;
let room3: Room; let room3: Room;
let viewModel: RoomListViewViewModel; let viewModel: RoomListViewModel;
beforeEach(() => { beforeEach(() => {
matrixClient = createTestClient(); matrixClient = createTestClient();
@@ -63,7 +63,7 @@ describe("RoomListViewViewModel", () => {
describe("Initialization", () => { describe("Initialization", () => {
it("should initialize with correct snapshot", () => { it("should initialize with correct snapshot", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
const snapshot = viewModel.getSnapshot(); const snapshot = viewModel.getSnapshot();
expect(snapshot.sections[0].roomIds).toEqual(["!room1:server", "!room2:server", "!room3:server"]); expect(snapshot.sections[0].roomIds).toEqual(["!room1:server", "!room2:server", "!room3:server"]);
@@ -80,7 +80,7 @@ describe("RoomListViewViewModel", () => {
rooms: [], rooms: [],
}); });
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
expect(viewModel.getSnapshot().sections[0].roomIds).toEqual([]); expect(viewModel.getSnapshot().sections[0].roomIds).toEqual([]);
expect(viewModel.getSnapshot().isRoomListEmpty).toBe(true); expect(viewModel.getSnapshot().isRoomListEmpty).toBe(true);
@@ -88,7 +88,7 @@ describe("RoomListViewViewModel", () => {
it("should set canCreateRoom based on user rights", () => { it("should set canCreateRoom based on user rights", () => {
mocked(hasCreateRoomRights).mockReturnValue(true); mocked(hasCreateRoomRights).mockReturnValue(true);
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
expect(viewModel.getSnapshot().canCreateRoom).toBe(true); expect(viewModel.getSnapshot().canCreateRoom).toBe(true);
}); });
@@ -96,7 +96,7 @@ describe("RoomListViewViewModel", () => {
describe("Room list updates", () => { describe("Room list updates", () => {
it("should update room list when ListsUpdate event fires", () => { it("should update room list when ListsUpdate event fires", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
const newRoom = mkStubRoom("!room4:server", "Room 4", matrixClient); const newRoom = mkStubRoom("!room4:server", "Room 4", matrixClient);
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
@@ -116,7 +116,7 @@ describe("RoomListViewViewModel", () => {
it("should update loading state when ListsLoaded event fires", () => { it("should update loading state when ListsLoaded event fires", () => {
jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(true); jest.spyOn(RoomListStoreV3.instance, "isLoadingRooms", "get").mockReturnValue(true);
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
expect(viewModel.getSnapshot().isLoadingRooms).toBe(true); expect(viewModel.getSnapshot().isLoadingRooms).toBe(true);
@@ -127,7 +127,7 @@ describe("RoomListViewViewModel", () => {
// This test ensures that the room list item vms are preserved when the room list is changing // This test ensures that the room list item vms are preserved when the room list is changing
it("should keep existing view model when ListsUpdate event fires", () => { it("should keep existing view model when ListsUpdate event fires", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
// Create view model for room1 // Create view model for room1
const room1VM = viewModel.getRoomItemViewModel("!room1:server"); const room1VM = viewModel.getRoomItemViewModel("!room1:server");
@@ -142,7 +142,7 @@ describe("RoomListViewViewModel", () => {
describe("Space switching", () => { describe("Space switching", () => {
it("should update room list when space changes", () => { it("should update room list when space changes", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
const spaceRoomList = [room1, room2]; const spaceRoomList = [room1, room2];
@@ -160,7 +160,7 @@ describe("RoomListViewViewModel", () => {
}); });
it("should clear view models when space changes", () => { it("should clear view models when space changes", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
// Get view models for visible rooms // Get view models for visible rooms
const vm1 = viewModel.getRoomItemViewModel("!room1:server"); const vm1 = viewModel.getRoomItemViewModel("!room1:server");
@@ -184,7 +184,7 @@ describe("RoomListViewViewModel", () => {
describe("Active room tracking", () => { describe("Active room tracking", () => {
it("should update active room index when room is selected", async () => { it("should update active room index when room is selected", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server"); jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server");
@@ -200,7 +200,7 @@ describe("RoomListViewViewModel", () => {
}); });
it("should return undefined active room index when no room is selected", async () => { it("should return undefined active room index when no room is selected", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(null); jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(null);
@@ -218,7 +218,7 @@ describe("RoomListViewViewModel", () => {
describe("Sticky room behavior", () => { describe("Sticky room behavior", () => {
it("should keep selected room at same index when room list updates", async () => { it("should keep selected room at same index when room list updates", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
// Select room at index 1 // Select room at index 1
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server"); jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server");
@@ -244,7 +244,7 @@ describe("RoomListViewViewModel", () => {
}); });
it("should not apply sticky behavior when user changes rooms", async () => { it("should not apply sticky behavior when user changes rooms", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
// Select room at index 1 // Select room at index 1
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server"); jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server");
@@ -270,7 +270,7 @@ describe("RoomListViewViewModel", () => {
describe("Filters", () => { describe("Filters", () => {
it("should toggle filter on", () => { it("should toggle filter on", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
expect(viewModel.getSnapshot().activeFilterId).toBeUndefined(); expect(viewModel.getSnapshot().activeFilterId).toBeUndefined();
@@ -287,7 +287,7 @@ describe("RoomListViewViewModel", () => {
}); });
it("should toggle filter off", () => { it("should toggle filter off", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
// Turn filter on // Turn filter on
jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({
@@ -317,7 +317,7 @@ describe("RoomListViewViewModel", () => {
describe("Room item view models", () => { describe("Room item view models", () => {
it("should create room item view model on demand", () => { it("should create room item view model on demand", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
const itemViewModel = viewModel.getRoomItemViewModel("!room1:server"); const itemViewModel = viewModel.getRoomItemViewModel("!room1:server");
@@ -326,7 +326,7 @@ describe("RoomListViewViewModel", () => {
}); });
it("should reuse existing room item view model", () => { it("should reuse existing room item view model", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
const itemViewModel1 = viewModel.getRoomItemViewModel("!room1:server"); const itemViewModel1 = viewModel.getRoomItemViewModel("!room1:server");
const itemViewModel2 = viewModel.getRoomItemViewModel("!room1:server"); const itemViewModel2 = viewModel.getRoomItemViewModel("!room1:server");
@@ -335,7 +335,7 @@ describe("RoomListViewViewModel", () => {
}); });
it("should throw error when requesting view model for non-existent room", () => { it("should throw error when requesting view model for non-existent room", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
expect(() => { expect(() => {
viewModel.getRoomItemViewModel("!nonexistent:server"); viewModel.getRoomItemViewModel("!nonexistent:server");
@@ -343,7 +343,7 @@ describe("RoomListViewViewModel", () => {
}); });
it("should dispose view models for rooms no longer visible", () => { it("should dispose view models for rooms no longer visible", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
const vm1 = viewModel.getRoomItemViewModel("!room1:server"); const vm1 = viewModel.getRoomItemViewModel("!room1:server");
const vm2 = viewModel.getRoomItemViewModel("!room2:server"); const vm2 = viewModel.getRoomItemViewModel("!room2:server");
@@ -366,7 +366,7 @@ describe("RoomListViewViewModel", () => {
describe("Room creation", () => { describe("Room creation", () => {
it("should dispatch CreateChat action when createChatRoom is called", () => { it("should dispatch CreateChat action when createChatRoom is called", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "fire"); const dispatchSpy = jest.spyOn(dispatcher, "fire");
@@ -376,7 +376,7 @@ describe("RoomListViewViewModel", () => {
}); });
it("should dispatch CreateRoom action without parent space", () => { it("should dispatch CreateRoom action without parent space", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
@@ -391,7 +391,7 @@ describe("RoomListViewViewModel", () => {
const spaceRoom = mkStubRoom("!space:server", "Space", matrixClient); const spaceRoom = mkStubRoom("!space:server", "Space", matrixClient);
jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(spaceRoom); jest.spyOn(SpaceStore.instance, "activeSpaceRoom", "get").mockReturnValue(spaceRoom);
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
const dispatchSpy = jest.spyOn(dispatcher, "dispatch"); const dispatchSpy = jest.spyOn(dispatcher, "dispatch");
@@ -411,7 +411,7 @@ describe("RoomListViewViewModel", () => {
}); });
it("should navigate to next room when delta is 1", async () => { it("should navigate to next room when delta is 1", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room1:server"); jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room1:server");
@@ -434,7 +434,7 @@ describe("RoomListViewViewModel", () => {
}); });
it("should navigate to previous room when delta is -1", async () => { it("should navigate to previous room when delta is -1", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server"); jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room2:server");
@@ -457,7 +457,7 @@ describe("RoomListViewViewModel", () => {
}); });
it("should wrap around to last room when navigating backwards from first room", async () => { it("should wrap around to last room when navigating backwards from first room", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room1:server"); jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!room1:server");
@@ -480,7 +480,7 @@ describe("RoomListViewViewModel", () => {
}); });
it("should not navigate when current room is not found", async () => { it("should not navigate when current room is not found", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!unknown:server"); jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue("!unknown:server");
@@ -504,7 +504,7 @@ describe("RoomListViewViewModel", () => {
}); });
it("should not navigate when no room is selected", async () => { it("should not navigate when no room is selected", async () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(null); jest.spyOn(SdkContextClass.instance.roomViewStore, "getRoomId").mockReturnValue(null);
@@ -529,7 +529,7 @@ describe("RoomListViewViewModel", () => {
describe("Cleanup", () => { describe("Cleanup", () => {
it("should dispose all room item view models on dispose", () => { it("should dispose all room item view models on dispose", () => {
viewModel = new RoomListViewViewModel({ client: matrixClient }); viewModel = new RoomListViewModel({ client: matrixClient });
const vm1 = viewModel.getRoomItemViewModel("!room1:server"); const vm1 = viewModel.getRoomItemViewModel("!room1:server");
const vm2 = viewModel.getRoomItemViewModel("!room2:server"); const vm2 = viewModel.getRoomItemViewModel("!room2:server");
+13
View File
@@ -18,6 +18,19 @@ If you do MVVM right, your view should be dumb i.e it gets data from the view mo
A first documentation and implementation of MVVM was done in [MVVM-v1.md](MVVM-v1.md). This v1 version is now deprecated and this document describes the current implementation. A first documentation and implementation of MVVM was done in [MVVM-v1.md](MVVM-v1.md). This v1 version is now deprecated and this document describes the current implementation.
#### Naming conventions
Given a feature named `Foo`, the naming convention for each MVVM artifact is:
| Artifact | Name |
| ----------------------------------- | ----------------- |
| View component | `FooView` |
| Snapshot interface | `FooViewSnapshot` |
| Actions interface | `FooViewActions` |
| ViewModel type alias (in view file) | `FooViewModel` |
| ViewModel class (in `apps/web`) | `FooViewModel` |
| ViewModel class file | `FooViewModel.ts` |
#### Model #### Model
This is anywhere your data or business logic comes from. If your view model is accessing something simple exposed from `matrix-js-sdk`, then the sdk is your model. If you're using something more high level in element-web to get your data/logic (eg: `MemberListStore`), then that becomes your model. This is anywhere your data or business logic comes from. If your view model is accessing something simple exposed from `matrix-js-sdk`, then the sdk is your model. If you're using something more high level in element-web to get your data/logic (eg: `MemberListStore`), then that becomes your model.
@@ -9,12 +9,12 @@ import React from "react";
import { fn } from "storybook/test"; import { fn } from "storybook/test";
import type { Meta, StoryFn } from "@storybook/react-vite"; import type { Meta, StoryFn } from "@storybook/react-vite";
import imageFile from "../../../static/element.png"; import imageFile from "../../../../static/element.png";
import { LinkPreview } from "./LinkPreview"; import { LinkPreview } from "./LinkPreview";
import { LinkedTextContext } from "../../utils/LinkedText"; import { LinkedTextContext } from "../../../utils/LinkedText";
export default { export default {
title: "Event/UrlPreviewView", title: "Event/UrlPreviewGroupView/LinkPreview",
component: LinkPreview, component: LinkPreview,
tags: ["autodocs"], tags: ["autodocs"],
args: { args: {
@@ -9,16 +9,16 @@ import React, { type MouseEventHandler, type JSX, useCallback, useMemo } from "r
import { Tooltip, Text } from "@vector-im/compound-web"; import { Tooltip, Text } from "@vector-im/compound-web";
import classNames from "classnames"; import classNames from "classnames";
import { useI18n } from "../../utils/i18nContext"; import { useI18n } from "../../../utils/i18nContext";
import styles from "./LinkPreview.module.css"; import styles from "./LinkPreview.module.css";
import type { UrlPreviewViewSnapshotPreview } from "./types"; import type { UrlPreview } from "../types";
import { LinkedText } from "../../utils/LinkedText"; import { LinkedText } from "../../../utils/LinkedText";
export interface LinkPreviewActions { export interface LinkPreviewActions {
onImageClick: () => void; onImageClick: () => void;
} }
export type LinkPreviewProps = UrlPreviewViewSnapshotPreview & LinkPreviewActions; export type LinkPreviewProps = UrlPreview & LinkPreviewActions;
/** /**
* LinkPreview renders a single preview component for a single link on an event. It is usually rendered as part of * LinkPreview renders a single preview component for a single link on an event. It is usually rendered as part of
@@ -0,0 +1,8 @@
/*
* 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 { LinkPreview } from "./LinkPreview";
@@ -12,12 +12,12 @@ import classNames from "classnames";
import { useViewModel, type ViewModel } from "../../viewmodel"; import { useViewModel, type ViewModel } from "../../viewmodel";
import { useI18n } from "../../utils/i18nContext"; import { useI18n } from "../../utils/i18nContext";
import type { UrlPreviewViewSnapshotPreview } from "./types"; import type { UrlPreview } from "./types";
import { LinkPreview } from "./LinkPreview"; import { LinkPreview } from "./LinkPreview";
import styles from "./UrlPreviewGroupView.module.css"; import styles from "./UrlPreviewGroupView.module.css";
export interface UrlPreviewGroupViewSnapshot { export interface UrlPreviewGroupViewSnapshot {
previews: Array<UrlPreviewViewSnapshotPreview>; previews: Array<UrlPreview>;
totalPreviewCount: number; totalPreviewCount: number;
previewsLimited: boolean; previewsLimited: boolean;
overPreviewLimit: boolean; overPreviewLimit: boolean;
@@ -31,9 +31,11 @@ export interface UrlPreviewGroupViewProps {
export interface UrlPreviewGroupViewActions { export interface UrlPreviewGroupViewActions {
onTogglePreviewLimit: () => void; onTogglePreviewLimit: () => void;
onHideClick: () => Promise<void>; onHideClick: () => Promise<void>;
onImageClick: (preview: UrlPreviewViewSnapshotPreview) => void; onImageClick: (preview: UrlPreview) => void;
} }
export type UrlPreviewGroupViewModel = ViewModel<UrlPreviewGroupViewSnapshot, UrlPreviewGroupViewActions>;
/** /**
* UrlPreviewGroupView renders a list of URL previews for a single event. * UrlPreviewGroupView renders a list of URL previews for a single event.
*/ */
@@ -10,6 +10,7 @@ export {
type UrlPreviewGroupViewSnapshot, type UrlPreviewGroupViewSnapshot,
type UrlPreviewGroupViewProps, type UrlPreviewGroupViewProps,
type UrlPreviewGroupViewActions, type UrlPreviewGroupViewActions,
type UrlPreviewGroupViewModel,
} from "./UrlPreviewGroupView"; } from "./UrlPreviewGroupView";
export { type UrlPreviewViewSnapshotPreview } from "./types"; export { type UrlPreview } from "./types";
@@ -5,7 +5,8 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
export interface UrlPreviewViewSnapshotPreview { /** Represents a URL preview. */
export interface UrlPreview {
/** /**
* The URL for the preview. * The URL for the preview.
*/ */
+1 -1
View File
@@ -16,7 +16,7 @@ export * from "./crypto/SasEmoji";
export * from "./event-tiles/EncryptionEventView"; export * from "./event-tiles/EncryptionEventView";
export * from "./event-tiles/EventTileBubble"; export * from "./event-tiles/EventTileBubble";
export * from "./event-tiles/TextualEventView"; export * from "./event-tiles/TextualEventView";
export * from "./event-tiles/UrlPreviewView"; export * from "./event-tiles/UrlPreviewGroupView";
export * from "./message-body/EventContentBody"; export * from "./message-body/EventContentBody";
export * from "./message-body/MediaBody"; export * from "./message-body/MediaBody";
export * from "./message-body/MessageTimestampView"; export * from "./message-body/MessageTimestampView";
@@ -12,14 +12,14 @@ import TriggerIcon from "@vector-im/compound-design-tokens/assets/web/icons/over
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import { import {
type WidgetContextMenuAction, type WidgetContextMenuViewActions,
type WidgetContextMenuSnapshot, type WidgetContextMenuViewSnapshot,
WidgetContextMenuView, WidgetContextMenuView,
} from "./WidgetContextMenuView"; } from "./WidgetContextMenuView";
import { useMockedViewModel } from "../../viewmodel/useMockedViewModel"; import { useMockedViewModel } from "../../viewmodel/useMockedViewModel";
import { withViewDocs } from "../../../.storybook/withViewDocs"; import { withViewDocs } from "../../../.storybook/withViewDocs";
type WidgetContextMenuViewModelProps = WidgetContextMenuSnapshot & WidgetContextMenuAction; type WidgetContextMenuViewModelProps = WidgetContextMenuViewSnapshot & WidgetContextMenuViewActions;
const WidgetContextMenuViewWrapperImpl = ({ const WidgetContextMenuViewWrapperImpl = ({
onStreamAudioClick, onStreamAudioClick,
@@ -14,8 +14,8 @@ import TriggerIcon from "@vector-im/compound-design-tokens/assets/web/icons/over
import { describe, vi, expect, it, afterEach } from "vitest"; import { describe, vi, expect, it, afterEach } from "vitest";
import { import {
type WidgetContextMenuAction, type WidgetContextMenuViewActions,
type WidgetContextMenuSnapshot, type WidgetContextMenuViewSnapshot,
WidgetContextMenuView, WidgetContextMenuView,
} from "./WidgetContextMenuView"; } from "./WidgetContextMenuView";
import * as stories from "./WidgetContextMenuView.stories.tsx"; import * as stories from "./WidgetContextMenuView.stories.tsx";
@@ -52,8 +52,8 @@ describe("<WidgetContextMenuView />", () => {
const onFinished = vi.fn(); const onFinished = vi.fn();
const onMoveButton = vi.fn(); const onMoveButton = vi.fn();
class WidgetContextMenuViewModel class WidgetContextMenuViewModel
extends MockViewModel<WidgetContextMenuSnapshot> extends MockViewModel<WidgetContextMenuViewSnapshot>
implements WidgetContextMenuAction implements WidgetContextMenuViewActions
{ {
public onKeyDown = onKeyDown; public onKeyDown = onKeyDown;
public togglePlay = togglePlay; public togglePlay = togglePlay;
@@ -68,7 +68,7 @@ describe("<WidgetContextMenuView />", () => {
public onMoveButton = onMoveButton; public onMoveButton = onMoveButton;
} }
const defaultValue: WidgetContextMenuSnapshot = { const defaultValue: WidgetContextMenuViewSnapshot = {
showStreamAudioStreamButton: true, showStreamAudioStreamButton: true,
showEditButton: true, showEditButton: true,
showRevokeButton: true, showRevokeButton: true,
@@ -13,7 +13,7 @@ import { type ViewModel } from "../../viewmodel/ViewModel.ts";
import { useI18n } from "../../utils/i18nContext.ts"; import { useI18n } from "../../utils/i18nContext.ts";
import { useViewModel } from "../../viewmodel/useViewModel.ts"; import { useViewModel } from "../../viewmodel/useViewModel.ts";
export interface WidgetContextMenuSnapshot { export interface WidgetContextMenuViewSnapshot {
/** /**
* Indicates if the audio stream button needs to be shown or not * Indicates if the audio stream button needs to be shown or not
* depending on the config value audio_stream_url and widget type jitsi * depending on the config value audio_stream_url and widget type jitsi
@@ -57,7 +57,7 @@ export interface WidgetContextMenuSnapshot {
userWidget: boolean; userWidget: boolean;
} }
export interface WidgetContextMenuAction { export interface WidgetContextMenuViewActions {
/** /**
* Function triggered when stream audio is clicked * Function triggered when stream audio is clicked
*/ */
@@ -89,7 +89,7 @@ export interface WidgetContextMenuAction {
onMoveButton: (direction: number) => void; onMoveButton: (direction: number) => void;
} }
export type WidgetContextMenuViewModel = ViewModel<WidgetContextMenuSnapshot, WidgetContextMenuAction>; export type WidgetContextMenuViewModel = ViewModel<WidgetContextMenuViewSnapshot, WidgetContextMenuViewActions>;
interface WidgetContextMenuViewProps { interface WidgetContextMenuViewProps {
vm: WidgetContextMenuViewModel; vm: WidgetContextMenuViewModel;
@@ -5,5 +5,5 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
export type { WidgetContextMenuSnapshot, WidgetContextMenuViewModel } from "./WidgetContextMenuView"; export type { WidgetContextMenuViewSnapshot, WidgetContextMenuViewModel } from "./WidgetContextMenuView";
export { WidgetContextMenuView } from "./WidgetContextMenuView"; export { WidgetContextMenuView } from "./WidgetContextMenuView";
@@ -9,14 +9,14 @@ import React, { type JSX, type PropsWithChildren } from "react";
import { ContextMenu } from "@vector-im/compound-web"; import { ContextMenu } from "@vector-im/compound-web";
import { _t } from "../../utils/i18n"; import { _t } from "../../utils/i18n";
import { MoreOptionContent, type RoomItemViewModel } from "./RoomListItemMoreOptionsMenu"; import { MoreOptionContent, type RoomListItemViewModel } from "./RoomListItemMoreOptionsMenu";
/** /**
* Props for RoomListItemContextMenu component * Props for RoomListItemContextMenu component
*/ */
export interface RoomListItemContextMenuProps { export interface RoomListItemContextMenuProps {
/** The room item view model */ /** The room item view model */
vm: RoomItemViewModel; vm: RoomListItemViewModel;
} }
/** /**
@@ -8,7 +8,7 @@
import React, { type JSX } from "react"; import React, { type JSX } from "react";
import { Flex } from "../../utils/Flex"; import { Flex } from "../../utils/Flex";
import { RoomListItemMoreOptionsMenu, type RoomItemViewModel } from "./RoomListItemMoreOptionsMenu"; import { RoomListItemMoreOptionsMenu, type RoomListItemViewModel } from "./RoomListItemMoreOptionsMenu";
import { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu"; import { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
import styles from "./RoomListItemView.module.css"; import styles from "./RoomListItemView.module.css";
@@ -21,7 +21,7 @@ export interface RoomListItemHoverMenuProps {
/** Whether the notification menu should be shown */ /** Whether the notification menu should be shown */
showNotificationMenu: boolean; showNotificationMenu: boolean;
/** The room item view model */ /** The room item view model */
vm: RoomItemViewModel; vm: RoomListItemViewModel;
} }
/** /**
@@ -12,7 +12,7 @@ import { describe, it, expect, vi } from "vitest";
import { RoomListItemMoreOptionsMenu } from "./RoomListItemMoreOptionsMenu"; import { RoomListItemMoreOptionsMenu } from "./RoomListItemMoreOptionsMenu";
import { useMockedViewModel } from "../../viewmodel"; import { useMockedViewModel } from "../../viewmodel";
import type { RoomListItemSnapshot } from "./RoomListItemView"; import type { RoomListItemViewSnapshot } from "./RoomListItemView";
import { defaultSnapshot } from "./default-snapshot"; import { defaultSnapshot } from "./default-snapshot";
describe("<RoomListItemMoreOptionsMenu />", () => { describe("<RoomListItemMoreOptionsMenu />", () => {
@@ -28,7 +28,7 @@ describe("<RoomListItemMoreOptionsMenu />", () => {
onSetRoomNotifState: vi.fn(), onSetRoomNotifState: vi.fn(),
}; };
const renderMenu = (overrides: Partial<RoomListItemSnapshot> = {}): ReturnType<typeof render> => { const renderMenu = (overrides: Partial<RoomListItemViewSnapshot> = {}): ReturnType<typeof render> => {
const TestComponent = (): JSX.Element => { const TestComponent = (): JSX.Element => {
const vm = useMockedViewModel( const vm = useMockedViewModel(
{ {
@@ -36,7 +36,7 @@ describe("<RoomListItemMoreOptionsMenu />", () => {
showMoreOptionsMenu: true, showMoreOptionsMenu: true,
showNotificationMenu: false, showNotificationMenu: false,
...overrides, ...overrides,
} as RoomListItemSnapshot, } as RoomListItemViewSnapshot,
mockCallbacks, mockCallbacks,
); );
return <RoomListItemMoreOptionsMenu vm={vm} />; return <RoomListItemMoreOptionsMenu vm={vm} />;
@@ -20,19 +20,19 @@ import {
import { _t } from "../../utils/i18n"; import { _t } from "../../utils/i18n";
import { useViewModel, type ViewModel } from "../../viewmodel"; import { useViewModel, type ViewModel } from "../../viewmodel";
import type { RoomListItemSnapshot, RoomListItemActions } from "./RoomListItemView"; import type { RoomListItemViewSnapshot, RoomListItemViewActions } from "./RoomListItemView";
/** /**
* View model type for room list item * View model type for room list item
*/ */
export type RoomItemViewModel = ViewModel<RoomListItemSnapshot, RoomListItemActions>; export type RoomListItemViewModel = ViewModel<RoomListItemViewSnapshot, RoomListItemViewActions>;
/** /**
* Props for RoomListItemMoreOptionsMenu component * Props for RoomListItemMoreOptionsMenu component
*/ */
export interface RoomListItemMoreOptionsMenuProps { export interface RoomListItemMoreOptionsMenuProps {
/** The room item view model */ /** The room item view model */
vm: RoomItemViewModel; vm: RoomListItemViewModel;
} }
/** /**
@@ -66,7 +66,7 @@ export function RoomListItemMoreOptionsMenu({ vm }: RoomListItemMoreOptionsMenuP
} }
interface MoreOptionContentProps { interface MoreOptionContentProps {
vm: RoomItemViewModel; vm: RoomListItemViewModel;
} }
export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element { export function MoreOptionContent({ vm }: MoreOptionContentProps): JSX.Element {
@@ -13,7 +13,7 @@ import { describe, it, expect, vi } from "vitest";
import { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu"; import { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
import { RoomNotifState } from "./RoomNotifs"; import { RoomNotifState } from "./RoomNotifs";
import { useMockedViewModel } from "../../viewmodel"; import { useMockedViewModel } from "../../viewmodel";
import type { RoomListItemSnapshot } from "./RoomListItemView"; import type { RoomListItemViewSnapshot } from "./RoomListItemView";
import { defaultSnapshot } from "./default-snapshot"; import { defaultSnapshot } from "./default-snapshot";
describe("<RoomListItemNotificationMenu />", () => { describe("<RoomListItemNotificationMenu />", () => {
@@ -37,7 +37,7 @@ describe("<RoomListItemNotificationMenu />", () => {
showMoreOptionsMenu: false, showMoreOptionsMenu: false,
showNotificationMenu: true, showNotificationMenu: true,
roomNotifState, roomNotifState,
} as RoomListItemSnapshot, } as RoomListItemViewSnapshot,
mockCallbacks, mockCallbacks,
); );
return <RoomListItemNotificationMenu vm={vm} />; return <RoomListItemNotificationMenu vm={vm} />;
@@ -16,19 +16,19 @@ import {
import { _t } from "../../utils/i18n"; import { _t } from "../../utils/i18n";
import { RoomNotifState } from "./RoomNotifs"; import { RoomNotifState } from "./RoomNotifs";
import { useViewModel, type ViewModel } from "../../viewmodel"; import { useViewModel, type ViewModel } from "../../viewmodel";
import type { RoomListItemSnapshot, RoomListItemActions } from "./RoomListItemView"; import type { RoomListItemViewSnapshot, RoomListItemViewActions } from "./RoomListItemView";
/** /**
* View model type for room list item * View model type for room list item
*/ */
export type RoomItemViewModel = ViewModel<RoomListItemSnapshot, RoomListItemActions>; export type RoomListItemViewModel = ViewModel<RoomListItemViewSnapshot, RoomListItemViewActions>;
/** /**
* Props for RoomListItemNotificationMenu component * Props for RoomListItemNotificationMenu component
*/ */
export interface RoomListItemNotificationMenuProps { export interface RoomListItemNotificationMenuProps {
/** The room item view model */ /** The room item view model */
vm: RoomItemViewModel; vm: RoomListItemViewModel;
} }
/** /**
@@ -10,15 +10,15 @@ import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import type { Room } from "./RoomListItemView"; import type { Room } from "./RoomListItemView";
import { RoomListItemView, type RoomListItemSnapshot, type RoomListItemActions } from "./RoomListItemView"; import { RoomListItemView, type RoomListItemViewSnapshot, type RoomListItemViewActions } from "./RoomListItemView";
import { useMockedViewModel } from "../../viewmodel"; import { useMockedViewModel } from "../../viewmodel";
import { withViewDocs } from "../../../.storybook/withViewDocs"; import { withViewDocs } from "../../../.storybook/withViewDocs";
import { defaultSnapshot } from "./default-snapshot"; import { defaultSnapshot } from "./default-snapshot";
import { renderAvatar } from "../story-mocks"; import { renderAvatar } from "../story-mocks";
import { mockedActions } from "./mocked-actions"; import { mockedActions } from "./mocked-actions";
type RoomListItemProps = RoomListItemSnapshot & type RoomListItemProps = RoomListItemViewSnapshot &
RoomListItemActions & { RoomListItemViewActions & {
isSelected: boolean; isSelected: boolean;
isFocused: boolean; isFocused: boolean;
onFocus: (room: Room, e: React.FocusEvent) => void; onFocus: (room: Room, e: React.FocusEvent) => void;
@@ -44,7 +44,7 @@ function getA11yLabel(roomName: string, notification: NotificationDecorationData
* Snapshot for a room list item. * Snapshot for a room list item.
* Contains all the data needed to render a room in the list. * Contains all the data needed to render a room in the list.
*/ */
export interface RoomListItemSnapshot { export interface RoomListItemViewSnapshot {
/** Unique identifier for the room (used for list keying) */ /** Unique identifier for the room (used for list keying) */
id: string; id: string;
/** The opaque Room object from the client (e.g., matrix-js-sdk Room) */ /** The opaque Room object from the client (e.g., matrix-js-sdk Room) */
@@ -81,7 +81,7 @@ export interface RoomListItemSnapshot {
* Actions interface for room list item operations. * Actions interface for room list item operations.
* Implemented by the room item view model. * Implemented by the room item view model.
*/ */
export interface RoomListItemActions { export interface RoomListItemViewActions {
/** Called when the room should be opened */ /** Called when the room should be opened */
onOpenRoom: () => void; onOpenRoom: () => void;
/** Called when the room should be marked as read */ /** Called when the room should be marked as read */
@@ -105,14 +105,14 @@ export interface RoomListItemActions {
/** /**
* The view model type for a room list item * The view model type for a room list item
*/ */
export type RoomItemViewModel = ViewModel<RoomListItemSnapshot, RoomListItemActions>; export type RoomListItemViewModel = ViewModel<RoomListItemViewSnapshot, RoomListItemViewActions>;
/** /**
* Props for RoomListItemView component * Props for RoomListItemView component
*/ */
export interface RoomListItemViewProps extends Omit<React.HTMLAttributes<HTMLButtonElement>, "onFocus"> { export interface RoomListItemViewProps extends Omit<React.HTMLAttributes<HTMLButtonElement>, "onFocus"> {
/** The room item view model */ /** The room item view model */
vm: RoomItemViewModel; vm: RoomListItemViewModel;
/** Whether the room is selected */ /** Whether the room is selected */
isSelected: boolean; isSelected: boolean;
/** Whether the room should be focused */ /** Whether the room should be focused */
@@ -5,12 +5,12 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
import { type RoomListItemSnapshot } from "./RoomListItemView"; import { type RoomListItemViewSnapshot } from "./RoomListItemView";
import { RoomNotifState } from "./RoomNotifs"; import { RoomNotifState } from "./RoomNotifs";
export const mockRoom = { name: "General" }; export const mockRoom = { name: "General" };
export const defaultSnapshot: RoomListItemSnapshot = { export const defaultSnapshot: RoomListItemViewSnapshot = {
id: "!room:server", id: "!room:server",
room: mockRoom, room: mockRoom,
name: "General", name: "General",
@@ -8,9 +8,9 @@
export { RoomListItemView } from "./RoomListItemView"; export { RoomListItemView } from "./RoomListItemView";
export type { export type {
Room, Room,
RoomListItemSnapshot, RoomListItemViewSnapshot,
RoomItemViewModel, RoomListItemViewModel,
RoomListItemActions, RoomListItemViewActions,
RoomListItemViewProps, RoomListItemViewProps,
} from "./RoomListItemView"; } from "./RoomListItemView";
export { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu"; export { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
@@ -7,9 +7,9 @@
import { fn } from "storybook/test"; import { fn } from "storybook/test";
import { type RoomListItemActions } from "./RoomListItemView"; import { type RoomListItemViewActions } from "./RoomListItemView";
export const mockedActions: RoomListItemActions = { export const mockedActions: RoomListItemViewActions = {
onOpenRoom: fn(), onOpenRoom: fn(),
onMarkAsRead: fn(), onMarkAsRead: fn(),
onMarkAsUnread: fn(), onMarkAsUnread: fn(),
@@ -11,7 +11,7 @@ import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import type { Room } from "../RoomListItemView"; import type { Room } from "../RoomListItemView";
import type { FilterId } from "../RoomListPrimaryFilters"; import type { FilterId } from "../RoomListPrimaryFilters";
import { RoomListView, type RoomListSnapshot, type RoomListViewActions } from "./RoomListView"; import { RoomListView, type RoomListViewSnapshot, type RoomListViewActions } from "./RoomListView";
import { useMockedViewModel } from "../../viewmodel"; import { useMockedViewModel } from "../../viewmodel";
import { withViewDocs } from "../../../.storybook/withViewDocs"; import { withViewDocs } from "../../../.storybook/withViewDocs";
import { import {
@@ -25,7 +25,8 @@ import {
mockLargeListRoomIds, mockLargeListRoomIds,
} from "../story-mocks"; } from "../story-mocks";
type RoomListViewProps = RoomListSnapshot & RoomListViewActions & { renderAvatar: (room: Room) => React.ReactElement }; type RoomListViewProps = RoomListViewSnapshot &
RoomListViewActions & { renderAvatar: (room: Room) => React.ReactElement };
const mockFilterIds: FilterId[] = ["unread", "people", "rooms", "favourite"]; const mockFilterIds: FilterId[] = ["unread", "people", "rooms", "favourite"];
@@ -12,7 +12,7 @@ import { RoomListPrimaryFilters, type FilterId } from "../RoomListPrimaryFilters
import { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton"; import { RoomListLoadingSkeleton } from "./RoomListLoadingSkeleton";
import { RoomListEmptyStateView } from "./RoomListEmptyStateView"; import { RoomListEmptyStateView } from "./RoomListEmptyStateView";
import { VirtualizedRoomListView, type RoomListViewState } from "../VirtualizedRoomListView"; import { VirtualizedRoomListView, type RoomListViewState } from "../VirtualizedRoomListView";
import { type Room, type RoomItemViewModel } from "../RoomListItemView"; import { type Room, type RoomListItemViewModel } from "../RoomListItemView";
import { type RoomListSectionHeaderViewModel } from "../RoomListSectionHeaderView"; import { type RoomListSectionHeaderViewModel } from "../RoomListSectionHeaderView";
export type RoomListSection = { export type RoomListSection = {
@@ -25,7 +25,7 @@ export type RoomListSection = {
/** /**
* Snapshot for the room list view * Snapshot for the room list view
*/ */
export type RoomListSnapshot = { export type RoomListViewSnapshot = {
/** Whether the rooms are currently loading */ /** Whether the rooms are currently loading */
isLoadingRooms: boolean; isLoadingRooms: boolean;
/** Whether the room list is empty */ /** Whether the room list is empty */
@@ -59,7 +59,7 @@ export interface RoomListViewActions {
/** Called to create a new room */ /** Called to create a new room */
createRoom: () => void; createRoom: () => void;
/** Get view model for a specific room (virtualization API) */ /** Get view model for a specific room (virtualization API) */
getRoomItemViewModel: (roomId: string) => RoomItemViewModel; getRoomItemViewModel: (roomId: string) => RoomListItemViewModel;
/** Called when the visible range changes (virtualization API) */ /** Called when the visible range changes (virtualization API) */
updateVisibleRooms: (startIndex: number, endIndex: number) => void; updateVisibleRooms: (startIndex: number, endIndex: number) => void;
/** Get view model for a specific section header (virtualization API) */ /** Get view model for a specific section header (virtualization API) */
@@ -69,7 +69,7 @@ export interface RoomListViewActions {
/** /**
* The view model type for the room list view * The view model type for the room list view
*/ */
export type RoomListViewModel = ViewModel<RoomListSnapshot, RoomListViewActions>; export type RoomListViewModel = ViewModel<RoomListViewSnapshot, RoomListViewActions>;
/** /**
* Props for RoomListView component * Props for RoomListView component
@@ -9,7 +9,7 @@ export { RoomListView } from "./RoomListView";
export type { export type {
RoomListViewProps, RoomListViewProps,
RoomListViewModel, RoomListViewModel,
RoomListSnapshot, RoomListViewSnapshot,
RoomListViewActions, RoomListViewActions,
RoomListSection, RoomListSection,
} from "./RoomListView"; } from "./RoomListView";
@@ -11,7 +11,7 @@ import { fn } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite"; import type { Meta, StoryObj } from "@storybook/react-vite";
import type { Room } from "../RoomListItemView"; import type { Room } from "../RoomListItemView";
import { VirtualizedRoomListView, type RoomListViewState } from "./VirtualizedRoomListView"; import { VirtualizedRoomListView, type RoomListViewState } from "./VirtualizedRoomListView";
import type { RoomListSnapshot, RoomListViewActions } from "../RoomListView"; import type { RoomListViewSnapshot, RoomListViewActions } from "../RoomListView";
import { useMockedViewModel } from "../../viewmodel"; import { useMockedViewModel } from "../../viewmodel";
import { withViewDocs } from "../../../.storybook/withViewDocs"; import { withViewDocs } from "../../../.storybook/withViewDocs";
import type { FilterId } from "../RoomListPrimaryFilters"; import type { FilterId } from "../RoomListPrimaryFilters";
@@ -23,7 +23,8 @@ import {
mock10RoomsSections, mock10RoomsSections,
} from "../story-mocks"; } from "../story-mocks";
type RoomListStoryProps = RoomListSnapshot & RoomListViewActions & { renderAvatar: (room: Room) => React.ReactElement }; type RoomListStoryProps = RoomListViewSnapshot &
RoomListViewActions & { renderAvatar: (room: Room) => React.ReactElement };
// Wrapper component that creates a mocked ViewModel // Wrapper component that creates a mocked ViewModel
const RoomListWrapperImpl = ({ const RoomListWrapperImpl = ({
@@ -17,7 +17,7 @@ import {
getContainerAccessibleProps, getContainerAccessibleProps,
type VirtualizedListContext, type VirtualizedListContext,
} from "../../utils/VirtualizedList"; } from "../../utils/VirtualizedList";
import type { RoomListSnapshot, RoomListViewModel } from "../RoomListView"; import type { RoomListViewSnapshot, RoomListViewModel } from "../RoomListView";
import { GroupedVirtualizedList } from "../../utils/VirtualizedList"; import { GroupedVirtualizedList } from "../../utils/VirtualizedList";
import { RoomListSectionHeaderView } from "../RoomListSectionHeaderView"; import { RoomListSectionHeaderView } from "../RoomListSectionHeaderView";
import { RoomListItemAccessibilityWrapper } from "../RoomListItemAccessibilityWrapper"; import { RoomListItemAccessibilityWrapper } from "../RoomListItemAccessibilityWrapper";
@@ -28,7 +28,7 @@ import { RoomListItemAccessibilityWrapper } from "../RoomListItemAccessibilityWr
export type FilterKey = string; export type FilterKey = string;
/** /**
* State for the room list data (nested within RoomListSnapshot) * State for the room list data (nested within RoomListViewSnapshot)
*/ */
export interface RoomListViewState { export interface RoomListViewState {
/** Optional active room index for keyboard navigation */ /** Optional active room index for keyboard navigation */
@@ -74,7 +74,7 @@ type Context = {
/** Active room index for keyboard navigation */ /** Active room index for keyboard navigation */
activeRoomIndex: number | undefined; activeRoomIndex: number | undefined;
/** Sections of the room list */ /** Sections of the room list */
sections: RoomListSnapshot["sections"]; sections: RoomListViewSnapshot["sections"];
/** Total number of rooms in the list */ /** Total number of rooms in the list */
roomCount: number; roomCount: number;
/** Number of sections in the list */ /** Number of sections in the list */
@@ -8,7 +8,12 @@
import React from "react"; import React from "react";
import { fn } from "storybook/test"; import { fn } from "storybook/test";
import { type Room, type RoomItemViewModel, type RoomListItemSnapshot, RoomNotifState } from "./RoomListItemView"; import {
type Room,
type RoomListItemViewModel,
type RoomListItemViewSnapshot,
RoomNotifState,
} from "./RoomListItemView";
import { type RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderView"; import { type RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderView";
import { MockViewModel } from "../viewmodel"; import { MockViewModel } from "../viewmodel";
@@ -74,7 +79,7 @@ const roomNames = [
/** /**
* Create a mock room item snapshot for stories * Create a mock room item snapshot for stories
*/ */
export const createMockRoomSnapshot = (id: string, name: string, index: number): RoomListItemSnapshot => ({ export const createMockRoomSnapshot = (id: string, name: string, index: number): RoomListItemViewSnapshot => ({
id, id,
room: { name }, room: { name },
name, name,
@@ -102,7 +107,7 @@ export const createMockRoomSnapshot = (id: string, name: string, index: number):
roomNotifState: RoomNotifState.AllMessages, roomNotifState: RoomNotifState.AllMessages,
}); });
export function createMockRoomItemViewModel(roomId: string, name: string, index: number): RoomItemViewModel { export function createMockRoomItemViewModel(roomId: string, name: string, index: number): RoomListItemViewModel {
const snapshot = createMockRoomSnapshot(roomId, name, index); const snapshot = createMockRoomSnapshot(roomId, name, index);
return { return {
getSnapshot: () => snapshot, getSnapshot: () => snapshot,
@@ -122,8 +127,8 @@ export function createMockRoomItemViewModel(roomId: string, name: string, index:
/** /**
* Create a mock getRoomItemViewModel function for stories * Create a mock getRoomItemViewModel function for stories
*/ */
export const createGetRoomItemViewModel = (roomIds: string[]): ((roomId: string) => RoomItemViewModel) => { export const createGetRoomItemViewModel = (roomIds: string[]): ((roomId: string) => RoomListItemViewModel) => {
const viewModels = new Map<string, RoomItemViewModel>(); const viewModels = new Map<string, RoomListItemViewModel>();
roomIds.forEach((roomId, index) => { roomIds.forEach((roomId, index) => {
const name = roomNames[index % roomNames.length]; const name = roomNames[index % roomNames.length];
viewModels.set(roomId, createMockRoomItemViewModel(roomId, name, index)); viewModels.set(roomId, createMockRoomItemViewModel(roomId, name, index));