1
0
mirror of https://github.com/element-hq/element-web.git synced 2025-08-09 14:42:51 +03:00

Initial structure for shared component views (#30216)

* Very first pass at shared component views

Turn the trivial TextualEvent into a shared component with a separate view
model for element web. Args to view model will probably change to be more
specific and VM typer needs abstracting out into an interface, but should
give the general idea.

* Remove old TextualEvent

* Pass showHiddenEvents

Because we used it anyway, we just cheated by getting it from the context

* Factor out common view model stuff

* Move ViewModel interface into the shared components

* Add tiny wrapper hook

* Move showHiddenEvents into props fully

* Fill in stories / test

* chore: setup storybook

cherry pick edc5e87056
from florianduros/storybook

* Add TextualEvent component to storybook

* Add mock view model & snapshot

* Remove old style stories entry

* Change import

* Change import

* Prettier

* Add paxckage patch to @types/mdx

for React 19 compat

* Pass getSnapshot as getServerSnapshot too

* Maybe make sonar regognise tests as tests

* Typo

* Use storybook reacvt-vite

There's no reason to use the react-webpack plugin just because our app
is stuck on webpack, it just means we have vite as a dependency too.

* Change here too

* Workaround for incomatible types in rollup

https://github.com/rollup/rollup/issues/5199

* Remove webpack styling addon

Not necessary now we're using vite

* Hopefully do screenshot testing...

* need newer node

* quote issues

* Make it an npm script

* colons

* use right port

* Install playwright browsers

* Try without the if

* Oh right, we need the headless shell

* Pass flag to store received screenshots

and upload diffs too

* Update snapshot from received

* Include platform in snapshot / received dir

because font rendering differs between platforms

* Suffix snapshots with platform instead

like we do for playwright

* Remove unnecessary env vars

and better name

* Add some comments

* Prettier

* Fix yarn.lock

* Memoise vm creation

Co-authored-by: Florian Duros <florianduros@element.io>

* Add implements

Co-authored-by: Florian Duros <florianduros@element.io>

* Fix listener interface

* Add implements

Co-authored-by: Florian Duros <florianduros@element.io>

* Fix types

* Fix more types

* Revert useMemo

as this isn't a hook

* Unused import

* Add missing playwright step

* Add return type annotation

* Change to add / remove subscription callback

* Change to 'add' rather than 'subs.subscribe'

* Add cache specifier for only shell playwright browsers

* Add copyright headers

---------

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
Co-authored-by: Florian Duros <florianduros@element.io>
This commit is contained in:
David Baker
2025-07-14 14:13:02 +01:00
committed by GitHub
parent 361d36272e
commit 4bbcb8bb5d
32 changed files with 2293 additions and 175 deletions

View File

@@ -1,6 +1,11 @@
module.exports = { module.exports = {
plugins: ["matrix-org", "eslint-plugin-react-compiler"], plugins: ["matrix-org", "eslint-plugin-react-compiler"],
extends: ["plugin:matrix-org/babel", "plugin:matrix-org/react", "plugin:matrix-org/a11y"], extends: [
"plugin:matrix-org/babel",
"plugin:matrix-org/react",
"plugin:matrix-org/a11y",
"plugin:storybook/recommended",
],
parserOptions: { parserOptions: {
project: ["./tsconfig.json"], project: ["./tsconfig.json"],
}, },

View File

@@ -0,0 +1,60 @@
name: Shared Component Visual Tests
on:
pull_request: {}
merge_group:
types: [checks_requested]
push:
branches: [develop, master]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true
permissions: {} # No permissions required
jobs:
testStorybook:
name: "Run Visual Tests"
runs-on: ubuntu-24.04
permissions:
actions: read
issues: read
pull-requests: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4
with:
persist-credentials: false
repository: element-hq/element-web
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
cache: "yarn"
node-version: "lts/*"
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Get installed Playwright version
id: playwright
run: echo "version=$(yarn list --pattern @playwright/test --depth=0 --json --non-interactive --no-progress | jq -r '.data.trees[].name')" >> $GITHUB_OUTPUT
- name: Cache playwright binaries
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright.outputs.version }}-onlyshell
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: "yarn playwright install --with-deps --only-shell"
- name: Run Visual tests
run: "yarn test:storybook:ci"
- name: Upload received images & diffs
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: received-images
path: playwright/shared-component-received

3
.gitignore vendored
View File

@@ -31,3 +31,6 @@ electron/pub
/index.html /index.html
# version file and tarball created by `npm pack` / `yarn pack` # version file and tarball created by `npm pack` / `yarn pack`
/git-revision.txt /git-revision.txt
*storybook.log
storybook-static

View File

@@ -0,0 +1,28 @@
/*
Copyright 2025 New Vector 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 { create } from "storybook/theming";
export default create({
base: "light",
// Colors
textColor: "#1b1d22",
colorSecondary: "#111111",
// UI
appBg: "#ffffff",
appContentBg: "#ffffff",
// Toolbar
barBg: "#ffffff",
brandTitle: "Element Web",
brandUrl: "https://github.com/element-hq/element-web",
brandImage: "https://element.io/images/logo-ele-secondary.svg",
brandTarget: "_self",
});

21
.storybook/main.ts Normal file
View File

@@ -0,0 +1,21 @@
/*
Copyright 2025 New Vector 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 type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/shared-components/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: ["@storybook/addon-docs", "@storybook/addon-designs"],
framework: "@storybook/react-vite",
core: {
disableTelemetry: true,
},
typescript: {
reactDocgen: "react-docgen-typescript",
},
};
export default config;

13
.storybook/manager.js Normal file
View File

@@ -0,0 +1,13 @@
/*
Copyright 2025 New Vector 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 { addons } from "storybook/manager-api";
import ElementTheme from "./ElementTheme";
addons.setConfig({
theme: ElementTheme,
});

10
.storybook/preview.css Normal file
View File

@@ -0,0 +1,10 @@
/*
Copyright 2025 New Vector 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.
*/
.docs-story {
background: var(--cpd-color-bg-canvas-default);
}

56
.storybook/preview.tsx Normal file
View File

@@ -0,0 +1,56 @@
import type { ArgTypes, Preview, Decorator } from "@storybook/react-vite";
import "../res/css/shared.pcss";
import "./preview.css";
import React, { useLayoutEffect } from "react";
export const globalTypes = {
theme: {
name: "Theme",
defaultValue: "system",
description: "Global theme for components",
toolbar: {
icon: "circlehollow",
title: "Theme",
items: [
{ title: "System", value: "system", icon: "browser" },
{ title: "Light", value: "light", icon: "sun" },
{ title: "Light (high contrast)", value: "light-hc", icon: "sun" },
{ title: "Dark", value: "dark", icon: "moon" },
{ title: "Dark (high contrast)", value: "dark-hc", icon: "moon" },
],
},
},
} satisfies ArgTypes;
const allThemesClasses = globalTypes.theme.toolbar.items.map(({ value }) => `cpd-theme-${value}`);
const ThemeSwitcher: React.FC<{
theme: string;
}> = ({ theme }) => {
useLayoutEffect(() => {
document.documentElement.classList.remove(...allThemesClasses);
if (theme !== "system") {
document.documentElement.classList.add(`cpd-theme-${theme}`);
}
return () => document.documentElement.classList.remove(...allThemesClasses);
}, [theme]);
return null;
};
const withThemeProvider: Decorator = (Story, context) => {
return (
<>
<ThemeSwitcher theme={context.globals.theme} />
<Story />
</>
);
};
const preview: Preview = {
tags: ["autodocs"],
decorators: [withThemeProvider],
};
export default preview;

37
.storybook/test-runner.js Normal file
View File

@@ -0,0 +1,37 @@
/*
Copyright 2025 New Vector 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 { waitForPageReady } from "@storybook/test-runner";
import { toMatchImageSnapshot } from "jest-image-snapshot";
const customSnapshotsDir = `${process.cwd()}/playwright/shared-component-snapshots/`;
const customReceivedDir = `${process.cwd()}/playwright/shared-component-received/`;
/**
* @type {import('@storybook/test-runner').TestRunnerConfig}
*/
const config = {
setup(page) {
expect.extend({ toMatchImageSnapshot });
},
async postVisit(page, context) {
await waitForPageReady(page);
// If you want to take screenshot of multiple browsers, use
// page.context().browser().browserType().name() to get the browser name to prefix the file name
const image = await page.screenshot();
expect(image).toMatchImageSnapshot({
customSnapshotsDir,
customSnapshotIdentifier: `${context.id}-${process.platform}`,
storeReceivedOnFailure: true,
customReceivedDir,
customDiffDir: customReceivedDir,
});
},
};
export default config;

8
declaration.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
/*
* Copyright 2025 New Vector 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.
*/
declare module "*.module.css";

View File

@@ -17,7 +17,7 @@ const config: Config = {
// This is needed to be able to load dual CJS/ESM WASM packages e.g. rust crypto & matrix-wywiwyg // This is needed to be able to load dual CJS/ESM WASM packages e.g. rust crypto & matrix-wywiwyg
customExportConditions: ["browser", "node"], customExportConditions: ["browser", "node"],
}, },
testMatch: ["<rootDir>/test/**/*-test.[tj]s?(x)"], testMatch: ["<rootDir>/test/**/*-test.[tj]s?(x)", "<rootDir>/src/shared-components/**/*.test.[t]s?(x)"],
globalSetup: "<rootDir>/test/globalSetup.ts", globalSetup: "<rootDir>/test/globalSetup.ts",
setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"], setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"],
setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"], setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"],

View File

@@ -65,7 +65,11 @@
"coverage": "yarn test --coverage", "coverage": "yarn test --coverage",
"analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp", "analyse:webpack-bundles": "webpack-bundle-analyzer webpack-stats.json webapp",
"update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js", "update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js",
"postinstall": "patch-package" "postinstall": "patch-package",
"storybook": "storybook dev -p 6007",
"build-storybook": "storybook build",
"test:storybook": "test-storybook --url http://localhost:6007/",
"test:storybook:ci": "concurrently -k -s first -n \"SB,TEST\" \"yarn storybook\" \"wait-on tcp:6007 && yarn test-storybook --url http://localhost:6007/ --ci --maxWorkers=2\""
}, },
"resolutions": { "resolutions": {
"**/pretty-format/react-is": "19.1.0", "**/pretty-format/react-is": "19.1.0",
@@ -187,6 +191,10 @@
"@principalstudio/html-webpack-inject-preload": "^1.2.7", "@principalstudio/html-webpack-inject-preload": "^1.2.7",
"@rrweb/types": "^2.0.0-alpha.18", "@rrweb/types": "^2.0.0-alpha.18",
"@sentry/webpack-plugin": "^3.0.0", "@sentry/webpack-plugin": "^3.0.0",
"@storybook/addon-designs": "^10.0.1",
"@storybook/addon-docs": "^9.0.12",
"@storybook/react-vite": "^9.0.15",
"@storybook/test-runner": "^0.23.0",
"@stylistic/eslint-plugin": "^5.0.0", "@stylistic/eslint-plugin": "^5.0.0",
"@svgr/webpack": "^8.0.0", "@svgr/webpack": "^8.0.0",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
@@ -246,6 +254,7 @@
"eslint-plugin-react": "^7.28.0", "eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124", "eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124",
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-storybook": "^9.0.12",
"eslint-plugin-unicorn": "^56.0.0", "eslint-plugin-unicorn": "^56.0.0",
"express": "^5.0.0", "express": "^5.0.0",
"fake-indexeddb": "^6.0.0", "fake-indexeddb": "^6.0.0",
@@ -257,6 +266,7 @@
"jest": "^29.6.2", "jest": "^29.6.2",
"jest-canvas-mock": "^2.5.2", "jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"jest-image-snapshot": "^6.5.1",
"jest-mock": "^29.6.2", "jest-mock": "^29.6.2",
"jest-raw-loader": "^1.0.1", "jest-raw-loader": "^1.0.1",
"jsqr": "^1.4.0", "jsqr": "^1.4.0",
@@ -285,6 +295,7 @@
"rimraf": "^6.0.0", "rimraf": "^6.0.0",
"semver": "^7.5.2", "semver": "^7.5.2",
"source-map-loader": "^5.0.0", "source-map-loader": "^5.0.0",
"storybook": "^9.0.12",
"stylelint": "^16.13.0", "stylelint": "^16.13.0",
"stylelint-config-standard": "^38.0.0", "stylelint-config-standard": "^38.0.0",
"stylelint-scss": "^6.0.0", "stylelint-scss": "^6.0.0",
@@ -294,6 +305,7 @@
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"typescript": "5.8.3", "typescript": "5.8.3",
"util": "^0.12.5", "util": "^0.12.5",
"vite": "^7.0.1",
"web-streams-polyfill": "^4.0.0", "web-streams-polyfill": "^4.0.0",
"webpack": "^5.89.0", "webpack": "^5.89.0",
"webpack-bundle-analyzer": "^4.8.0", "webpack-bundle-analyzer": "^4.8.0",

View File

@@ -0,0 +1,46 @@
diff --git a/node_modules/@types/mdx/types.d.ts b/node_modules/@types/mdx/types.d.ts
index 498bb69..4e89216 100644
--- a/node_modules/@types/mdx/types.d.ts
+++ b/node_modules/@types/mdx/types.d.ts
@@ -5,7 +5,7 @@
*/
// @ts-ignore JSX runtimes may optionally define JSX.ElementType. The MDX types need to work regardless whether this is
// defined or not.
-type ElementType = any extends JSX.ElementType ? never : JSX.ElementType;
+type ElementType = any extends JSX.ElementType ? never : React.JSX.ElementType;
/**
* This matches any function component types that ar part of `ElementType`.
@@ -20,12 +20,12 @@ type ClassElementType = Extract<ElementType, new(props: Record<string, any>) =>
/**
* A valid JSX string component.
*/
-type StringComponent = Extract<keyof JSX.IntrinsicElements, ElementType extends never ? string : ElementType>;
+type StringComponent = Extract<keyof React.JSX.IntrinsicElements, ElementType extends never ? string : ElementType>;
/**
* A JSX element returned by MDX content.
*/
-export type Element = JSX.Element;
+export type Element = React.JSX.Element;
/**
* A valid JSX function component.
@@ -44,7 +44,7 @@ type FunctionComponent<Props> = ElementType extends never
*/
type ClassComponent<Props> = ElementType extends never
// If JSX.ElementType isnt defined, the valid return type is a constructor that returns JSX.ElementClass
- ? new(props: Props) => JSX.ElementClass
+ ? new(props: Props) => React.JSX.ElementClass
: ClassElementType extends never
// If JSX.ElementType is defined, but doesnt allow constructors, function components are disallowed.
? never
@@ -70,7 +70,7 @@ interface NestedMDXComponents {
export type MDXComponents =
& NestedMDXComponents
& {
- [Key in StringComponent]?: Component<JSX.IntrinsicElements[Key]>;
+ [Key in StringComponent]?: Component<React.JSX.IntrinsicElements[Key]>;
}
& {
/**

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

9
res/css/shared.pcss Normal file
View File

@@ -0,0 +1,9 @@
/*
* Copyright 2025 New Vector 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 url("@vector-im/compound-design-tokens/assets/web/css/compound-design-tokens.css") layer(compound);
@import url("@vector-im/compound-web/dist/style.css");

View File

@@ -5,7 +5,8 @@ sonar.organization=element-hq
#sonar.sourceEncoding=UTF-8 #sonar.sourceEncoding=UTF-8
sonar.sources=src,res sonar.sources=src,res
sonar.tests=test,playwright sonar.tests=test,playwright,src
sonar.test.inclusions=test/*,playwright/*,src/**/*.test.tsx
sonar.exclusions=__mocks__,docs,element.io,nginx sonar.exclusions=__mocks__,docs,element.io,nginx
sonar.cpd.exclusions=src/i18n/strings/*.json sonar.cpd.exclusions=src/i18n/strings/*.json

View File

@@ -1,47 +0,0 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2021 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 from "react";
import { type MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import RoomContext from "../../../contexts/RoomContext";
import * as TextForEvent from "../../../TextForEvent";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
interface IProps {
mxEvent: MatrixEvent;
}
export default class TextualEvent extends React.Component<IProps> {
public static contextType = RoomContext;
declare public context: React.ContextType<typeof RoomContext>;
public componentDidMount(): void {
this.props.mxEvent.on(MatrixEventEvent.SentinelUpdated, this.onEventSentinelUpdated);
}
public componentWillUnmount(): void {
this.props.mxEvent.off(MatrixEventEvent.SentinelUpdated, this.onEventSentinelUpdated);
}
private onEventSentinelUpdated = (): void => {
// XXX: this is crap, but we don't have a better way to force a re-render
// Many TextForEvent handlers render parts of `event.sender` and `event.target` so ensure they are updated
this.forceUpdate();
};
public render(): React.ReactNode {
const text = TextForEvent.textForEvent(
this.props.mxEvent,
MatrixClientPeg.safeGet(),
true,
this.context?.showHiddenEvents,
);
if (!text) return null;
return <div className="mx_TextualEvent">{text}</div>;
}
}

View File

@@ -1237,22 +1237,19 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}> <div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
{this.renderContextMenu()} {this.renderContextMenu()}
{replyChain} {replyChain}
{renderTile( {renderTile(TimelineRenderingType.Thread, {
TimelineRenderingType.Thread, ...this.props,
{
...this.props,
// overrides // overrides
ref: this.tile, ref: this.tile,
isSeeingThroughMessageHiddenForModeration, isSeeingThroughMessageHiddenForModeration,
// appease TS // appease TS
highlights: this.props.highlights, highlights: this.props.highlights,
highlightLink: this.props.highlightLink, highlightLink: this.props.highlightLink,
permalinkCreator: this.props.permalinkCreator!, permalinkCreator: this.props.permalinkCreator!,
}, showHiddenEvents: this.context.showHiddenEvents,
this.context.showHiddenEvents, })}
)}
{actionBar} {actionBar}
<a href={permalink} onClick={this.onPermalinkClicked}> <a href={permalink} onClick={this.onPermalinkClicked}>
{timestamp} {timestamp}
@@ -1383,22 +1380,19 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
</a>, </a>,
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}> <div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
{this.renderContextMenu()} {this.renderContextMenu()}
{renderTile( {renderTile(TimelineRenderingType.File, {
TimelineRenderingType.File, ...this.props,
{
...this.props,
// overrides // overrides
ref: this.tile, ref: this.tile,
isSeeingThroughMessageHiddenForModeration, isSeeingThroughMessageHiddenForModeration,
// appease TS // appease TS
highlights: this.props.highlights, highlights: this.props.highlights,
highlightLink: this.props.highlightLink, highlightLink: this.props.highlightLink,
permalinkCreator: this.props.permalinkCreator, permalinkCreator: this.props.permalinkCreator,
}, showHiddenEvents: this.context.showHiddenEvents,
this.context.showHiddenEvents, })}
)}
</div>, </div>,
], ],
); );
@@ -1433,23 +1427,20 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
{groupTimestamp} {groupTimestamp}
{groupPadlock} {groupPadlock}
{replyChain} {replyChain}
{renderTile( {renderTile(this.context.timelineRenderingType, {
this.context.timelineRenderingType, ...this.props,
{
...this.props,
// overrides // overrides
ref: this.tile, ref: this.tile,
isSeeingThroughMessageHiddenForModeration, isSeeingThroughMessageHiddenForModeration,
timestamp: bubbleTimestamp, timestamp: bubbleTimestamp,
// appease TS // appease TS
highlights: this.props.highlights, highlights: this.props.highlights,
highlightLink: this.props.highlightLink, highlightLink: this.props.highlightLink,
permalinkCreator: this.props.permalinkCreator, permalinkCreator: this.props.permalinkCreator,
}, showHiddenEvents: this.context.showHiddenEvents,
this.context.showHiddenEvents, })}
)}
{actionBar} {actionBar}
{this.props.layout === Layout.IRC && ( {this.props.layout === Layout.IRC && (
<> <>

View File

@@ -163,6 +163,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
highlights: this.props.highlights, highlights: this.props.highlights,
highlightLink: this.props.highlightLink, highlightLink: this.props.highlightLink,
permalinkCreator: this.props.permalinkCreator, permalinkCreator: this.props.permalinkCreator,
showHiddenEvents: false,
}, },
false /* showHiddenEvents shouldn't be relevant */, false /* showHiddenEvents shouldn't be relevant */,
)} )}

View File

@@ -26,7 +26,6 @@ import { TimelineRenderingType } from "../contexts/RoomContext";
import MessageEvent from "../components/views/messages/MessageEvent"; import MessageEvent from "../components/views/messages/MessageEvent";
import LegacyCallEvent from "../components/views/messages/LegacyCallEvent"; import LegacyCallEvent from "../components/views/messages/LegacyCallEvent";
import { CallEvent } from "../components/views/messages/CallEvent"; import { CallEvent } from "../components/views/messages/CallEvent";
import TextualEvent from "../components/views/messages/TextualEvent";
import EncryptionEvent from "../components/views/messages/EncryptionEvent"; import EncryptionEvent from "../components/views/messages/EncryptionEvent";
import { RoomPredecessorTile } from "../components/views/messages/RoomPredecessorTile"; import { RoomPredecessorTile } from "../components/views/messages/RoomPredecessorTile";
import RoomAvatarEvent from "../components/views/messages/RoomAvatarEvent"; import RoomAvatarEvent from "../components/views/messages/RoomAvatarEvent";
@@ -44,6 +43,8 @@ import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
import { ElementCall } from "../models/Call"; import { ElementCall } from "../models/Call";
import { type IBodyProps } from "../components/views/messages/IBodyProps"; import { type IBodyProps } from "../components/views/messages/IBodyProps";
import ModuleApi from "../modules/Api"; import ModuleApi from "../modules/Api";
import { TextualEventViewModel } from "../viewmodels/event-tiles/TextualEventViewModel";
import { TextualEvent } from "../shared-components/event-tiles/TextualEvent";
// Subset of EventTile's IProps plus some mixins // Subset of EventTile's IProps plus some mixins
export interface EventTileTypeProps export interface EventTileTypeProps
@@ -67,6 +68,7 @@ export interface EventTileTypeProps
maxImageHeight?: number; // pixels maxImageHeight?: number; // pixels
overrideBodyTypes?: Record<string, React.ComponentType<IBodyProps>>; overrideBodyTypes?: Record<string, React.ComponentType<IBodyProps>>;
overrideEventTypes?: Record<string, React.ComponentType<IBodyProps>>; overrideEventTypes?: Record<string, React.ComponentType<IBodyProps>>;
showHiddenEvents: boolean;
} }
type FactoryProps = Omit<EventTileTypeProps, "ref">; type FactoryProps = Omit<EventTileTypeProps, "ref">;
@@ -77,7 +79,10 @@ const LegacyCallEventFactory: Factory<FactoryProps & { callEventGrouper: LegacyC
<LegacyCallEvent ref={ref} {...props} /> <LegacyCallEvent ref={ref} {...props} />
); );
const CallEventFactory: Factory = (ref, props) => <CallEvent ref={ref} {...props} />; const CallEventFactory: Factory = (ref, props) => <CallEvent ref={ref} {...props} />;
export const TextualEventFactory: Factory = (ref, props) => <TextualEvent ref={ref} {...props} />; export const TextualEventFactory: Factory = (ref, props) => {
const vm = new TextualEventViewModel(props);
return <TextualEvent vm={vm} />;
};
const VerificationReqFactory: Factory = (_ref, props) => <MKeyVerificationRequest {...props} />; const VerificationReqFactory: Factory = (_ref, props) => <MKeyVerificationRequest {...props} />;
const HiddenEventFactory: Factory = (ref, props) => <HiddenBody ref={ref} {...props} />; const HiddenEventFactory: Factory = (ref, props) => <HiddenBody ref={ref} {...props} />;
@@ -252,12 +257,11 @@ export function pickFactory(
export function renderTile( export function renderTile(
renderType: TimelineRenderingType, renderType: TimelineRenderingType,
props: EventTileTypeProps, props: EventTileTypeProps,
showHiddenEvents: boolean,
cli?: MatrixClient, cli?: MatrixClient,
): Optional<JSX.Element> { ): Optional<JSX.Element> {
cli = cli ?? MatrixClientPeg.safeGet(); // because param defaults don't do the correct thing cli = cli ?? MatrixClientPeg.safeGet(); // because param defaults don't do the correct thing
const factory = pickFactory(props.mxEvent, cli, showHiddenEvents); const factory = pickFactory(props.mxEvent, cli, props.showHiddenEvents);
if (!factory) { if (!factory) {
// If we don't have a factory for this event, attempt // If we don't have a factory for this event, attempt
// to find a custom component that can render it. // to find a custom component that can render it.
@@ -286,6 +290,7 @@ export function renderTile(
isSeeingThroughMessageHiddenForModeration, isSeeingThroughMessageHiddenForModeration,
timestamp, timestamp,
inhibitInteraction, inhibitInteraction,
showHiddenEvents,
} = props; } = props;
switch (renderType) { switch (renderType) {
@@ -309,6 +314,7 @@ export function renderTile(
isSeeingThroughMessageHiddenForModeration, isSeeingThroughMessageHiddenForModeration,
permalinkCreator, permalinkCreator,
inhibitInteraction, inhibitInteraction,
showHiddenEvents,
}), }),
); );
default: default:
@@ -332,6 +338,7 @@ export function renderTile(
isSeeingThroughMessageHiddenForModeration, isSeeingThroughMessageHiddenForModeration,
timestamp, timestamp,
inhibitInteraction, inhibitInteraction,
showHiddenEvents,
}), }),
); );
} }
@@ -394,6 +401,7 @@ export function renderReplyTile(
getRelationsForEvent, getRelationsForEvent,
isSeeingThroughMessageHiddenForModeration, isSeeingThroughMessageHiddenForModeration,
permalinkCreator, permalinkCreator,
showHiddenEvents,
}), }),
); );
} }

View File

@@ -0,0 +1,23 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type ViewModel } from "./ViewModel";
/**
* A mock view model that returns a static snapshot passed in the constructor, with no updates.
*/
export class MockViewModel<T> implements ViewModel<T> {
public constructor(private snapshot: T) {}
public getSnapshot = (): T => {
return this.snapshot;
};
public subscribe(listener: () => void): () => void {
return () => undefined;
}
}

View File

@@ -0,0 +1,23 @@
/*
Copyright 2025 New Vector 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.
*/
/**
* The interface for a generic View Model passed to the shared components.
* The snapshot is of type T which is a type specifying a snapshot for the view in question.
*/
export interface ViewModel<T> {
/**
* The current snapshot of the view model.
*/
getSnapshot: () => T;
/**
* Subscribes to changes in the view model.
* The listener will be called whenever the snapshot changes.
*/
subscribe: (listener: () => void) => () => void;
}

View File

@@ -0,0 +1,25 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { type Meta, type StoryFn } from "@storybook/react-vite";
import { TextualEvent as TextualEventComponent } from "./TextualEvent";
import { MockViewModel } from "../../MockViewModel";
export default {
title: "Event/TextualEvent",
component: TextualEventComponent,
tags: ["autodocs"],
args: {
vm: new MockViewModel("Dummy textual event text"),
},
} as Meta<typeof TextualEventComponent>;
const Template: StoryFn<typeof TextualEventComponent> = (args) => <TextualEventComponent {...args} />;
export const Default = Template.bind({});

View File

@@ -0,0 +1,21 @@
/*
Copyright 2025 New Vector 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 { composeStories } from "@storybook/react-vite";
import { render } from "jest-matrix-react";
import React from "react";
import * as stories from "./TextualEvent.stories.tsx";
const { Default } = composeStories(stories);
describe("TextualEvent", () => {
it("renders a textual event", () => {
const { container } = render(<Default />);
expect(container).toMatchSnapshot();
});
});

View File

@@ -0,0 +1,23 @@
/*
Copyright 2025 New Vector 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 ReactNode, type JSX } from "react";
import { type ViewModel } from "../../ViewModel";
import { useViewModel } from "../../useViewModel";
export type TextualEventViewSnapshot = string | ReactNode;
export interface Props {
vm: ViewModel<TextualEventViewSnapshot>;
}
export function TextualEvent({ vm }: Props): JSX.Element {
const contents = useViewModel(vm);
return <div className="mx_TextualEvent">{contents}</div>;
}

View File

@@ -0,0 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TextualEvent renders a textual event 1`] = `
<div>
<div
class="mx_TextualEvent"
>
Dummy textual event text
</div>
</div>
`;

View File

@@ -0,0 +1,8 @@
/*
Copyright 2025 New Vector 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 { TextualEvent } from "./TextualEvent";

View File

@@ -0,0 +1,21 @@
/*
Copyright 2025 New Vector 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 { useSyncExternalStore } from "react";
import { type ViewModel } from "./ViewModel";
/**
* A small wrapper around useSyncExternalStore to use a view model in a shared component view
* @param vm The view model to use
* @returns The current snapshot
*/
export function useViewModel<T>(vm: ViewModel<T>): T {
// We need to pass the same getSnapshot function as getServerSnapshot as this
// is used when making the HTML chat export.
return useSyncExternalStore(vm.subscribe, vm.getSnapshot, vm.getSnapshot);
}

View File

@@ -0,0 +1,57 @@
/*
Copyright 2025 New Vector 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.
*/
/**
* Utility class for view models to manage suscriptions to their updates
*/
export class ViewModelSubscriptions {
private listeners = new Set<() => void>();
/**
* @param updateSubscription A function called whenever a listener is added or removed.
*/
public constructor(
private subscribeCallback: () => void,
private unsubscribeCallback: () => void,
) {}
/**
* Subscribe to changes in the view model.
* @param listener Will be called whenever the snapshot changes.
* @returns A function to unsubscribe from the view model updates.
*/
public add = (listener: () => void): (() => void) => {
this.listeners.add(listener);
if (this.listeners.size === 1) {
this.subscribeCallback();
}
return () => {
this.listeners.delete(listener);
if (this.listeners.size === 0) {
this.unsubscribeCallback();
}
};
};
/**
* Emit an update to all subscribed listeners.
*/
public emit(): void {
for (const listener of this.listeners) {
listener();
}
}
/**
* Get the number of listeners currently subscribed to updates.
* @returns The number of listeners.
*/
public listenerCount(): number {
return this.listeners.size;
}
}

View File

@@ -0,0 +1,49 @@
/*
Copyright 2025 New Vector 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 { MatrixEventEvent } from "matrix-js-sdk/src/matrix";
import { type EventTileTypeProps } from "../../events/EventTileFactory";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import { textForEvent } from "../../TextForEvent";
import { ViewModelSubscriptions } from "../ViewModelSubscriptions";
import { type TextualEventViewSnapshot } from "../../shared-components/event-tiles/TextualEvent/TextualEvent";
import { type ViewModel } from "../../shared-components/ViewModel";
export class TextualEventViewModel implements ViewModel<TextualEventViewSnapshot> {
private subs: ViewModelSubscriptions;
public constructor(private eventTileProps: EventTileTypeProps) {
this.subs = new ViewModelSubscriptions(this.addSubscription, this.removeSubscription);
}
private addSubscription = (): void => {
this.eventTileProps.mxEvent.on(MatrixEventEvent.SentinelUpdated, this.onEventSentinelUpdated);
};
private removeSubscription = (): void => {
this.eventTileProps.mxEvent.off(MatrixEventEvent.SentinelUpdated, this.onEventSentinelUpdated);
};
public subscribe = (listener: () => void): (() => void) => {
return this.subs.add(listener);
};
public getSnapshot = (): TextualEventViewSnapshot => {
const text = textForEvent(
this.eventTileProps.mxEvent,
MatrixClientPeg.safeGet(),
true,
this.eventTileProps.showHiddenEvents,
);
return text;
};
private onEventSentinelUpdated = (): void => {
this.subs.emit();
};
}

View File

@@ -17,7 +17,8 @@
"lib": ["es2022", "es2024.promise", "dom", "dom.iterable"], "lib": ["es2022", "es2024.promise", "dom", "dom.iterable"],
"strict": true, "strict": true,
"paths": { "paths": {
"jest-matrix-react": ["./test/test-utils/jest-matrix-react"] "jest-matrix-react": ["./test/test-utils/jest-matrix-react"],
"rollup/parseAst": ["./node_modules/rollup/dist/parseAst"]
} }
}, },
"include": [ "include": [
@@ -26,7 +27,8 @@
"./src/**/*.tsx", "./src/**/*.tsx",
"./test/**/*.ts", "./test/**/*.ts",
"./test/**/*.tsx", "./test/**/*.tsx",
"./scripts/*.ts" "./scripts/*.ts",
"./declaration.d.ts"
], ],
"ts-node": { "ts-node": {
"files": true, "files": true,

1743
yarn.lock

File diff suppressed because it is too large Load Diff