You've already forked element-web
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:
@@ -1,6 +1,11 @@
|
||||
module.exports = {
|
||||
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: {
|
||||
project: ["./tsconfig.json"],
|
||||
},
|
||||
|
60
.github/workflows/shared-component-visual-tests.yaml
vendored
Normal file
60
.github/workflows/shared-component-visual-tests.yaml
vendored
Normal 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
3
.gitignore
vendored
@@ -31,3 +31,6 @@ electron/pub
|
||||
/index.html
|
||||
# version file and tarball created by `npm pack` / `yarn pack`
|
||||
/git-revision.txt
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
|
28
.storybook/ElementTheme.ts
Normal file
28
.storybook/ElementTheme.ts
Normal 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
21
.storybook/main.ts
Normal 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
13
.storybook/manager.js
Normal 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
10
.storybook/preview.css
Normal 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
56
.storybook/preview.tsx
Normal 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
37
.storybook/test-runner.js
Normal 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
8
declaration.d.ts
vendored
Normal 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";
|
@@ -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
|
||||
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",
|
||||
setupFiles: ["jest-canvas-mock", "web-streams-polyfill/polyfill"],
|
||||
setupFilesAfterEnv: ["<rootDir>/test/setupTests.ts"],
|
||||
|
14
package.json
14
package.json
@@ -65,7 +65,11 @@
|
||||
"coverage": "yarn test --coverage",
|
||||
"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",
|
||||
"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": {
|
||||
"**/pretty-format/react-is": "19.1.0",
|
||||
@@ -187,6 +191,10 @@
|
||||
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
|
||||
"@rrweb/types": "^2.0.0-alpha.18",
|
||||
"@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",
|
||||
"@svgr/webpack": "^8.0.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
@@ -246,6 +254,7 @@
|
||||
"eslint-plugin-react": "^7.28.0",
|
||||
"eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-storybook": "^9.0.12",
|
||||
"eslint-plugin-unicorn": "^56.0.0",
|
||||
"express": "^5.0.0",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
@@ -257,6 +266,7 @@
|
||||
"jest": "^29.6.2",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jest-image-snapshot": "^6.5.1",
|
||||
"jest-mock": "^29.6.2",
|
||||
"jest-raw-loader": "^1.0.1",
|
||||
"jsqr": "^1.4.0",
|
||||
@@ -285,6 +295,7 @@
|
||||
"rimraf": "^6.0.0",
|
||||
"semver": "^7.5.2",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"storybook": "^9.0.12",
|
||||
"stylelint": "^16.13.0",
|
||||
"stylelint-config-standard": "^38.0.0",
|
||||
"stylelint-scss": "^6.0.0",
|
||||
@@ -294,6 +305,7 @@
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "5.8.3",
|
||||
"util": "^0.12.5",
|
||||
"vite": "^7.0.1",
|
||||
"web-streams-polyfill": "^4.0.0",
|
||||
"webpack": "^5.89.0",
|
||||
"webpack-bundle-analyzer": "^4.8.0",
|
||||
|
46
patches/@types+mdx+2.0.13.patch
Normal file
46
patches/@types+mdx+2.0.13.patch
Normal 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 isn’t 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 doesn’t 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
9
res/css/shared.pcss
Normal 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");
|
@@ -5,7 +5,8 @@ sonar.organization=element-hq
|
||||
#sonar.sourceEncoding=UTF-8
|
||||
|
||||
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.cpd.exclusions=src/i18n/strings/*.json
|
||||
|
@@ -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>;
|
||||
}
|
||||
}
|
@@ -1237,9 +1237,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
|
||||
{this.renderContextMenu()}
|
||||
{replyChain}
|
||||
{renderTile(
|
||||
TimelineRenderingType.Thread,
|
||||
{
|
||||
{renderTile(TimelineRenderingType.Thread, {
|
||||
...this.props,
|
||||
|
||||
// overrides
|
||||
@@ -1250,9 +1248,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
highlights: this.props.highlights,
|
||||
highlightLink: this.props.highlightLink,
|
||||
permalinkCreator: this.props.permalinkCreator!,
|
||||
},
|
||||
this.context.showHiddenEvents,
|
||||
)}
|
||||
showHiddenEvents: this.context.showHiddenEvents,
|
||||
})}
|
||||
{actionBar}
|
||||
<a href={permalink} onClick={this.onPermalinkClicked}>
|
||||
{timestamp}
|
||||
@@ -1383,9 +1380,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
</a>,
|
||||
<div className={lineClasses} key="mx_EventTile_line" onContextMenu={this.onContextMenu}>
|
||||
{this.renderContextMenu()}
|
||||
{renderTile(
|
||||
TimelineRenderingType.File,
|
||||
{
|
||||
{renderTile(TimelineRenderingType.File, {
|
||||
...this.props,
|
||||
|
||||
// overrides
|
||||
@@ -1396,9 +1391,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
highlights: this.props.highlights,
|
||||
highlightLink: this.props.highlightLink,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
},
|
||||
this.context.showHiddenEvents,
|
||||
)}
|
||||
showHiddenEvents: this.context.showHiddenEvents,
|
||||
})}
|
||||
</div>,
|
||||
],
|
||||
);
|
||||
@@ -1433,9 +1427,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
{groupTimestamp}
|
||||
{groupPadlock}
|
||||
{replyChain}
|
||||
{renderTile(
|
||||
this.context.timelineRenderingType,
|
||||
{
|
||||
{renderTile(this.context.timelineRenderingType, {
|
||||
...this.props,
|
||||
|
||||
// overrides
|
||||
@@ -1447,9 +1439,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
highlights: this.props.highlights,
|
||||
highlightLink: this.props.highlightLink,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
},
|
||||
this.context.showHiddenEvents,
|
||||
)}
|
||||
showHiddenEvents: this.context.showHiddenEvents,
|
||||
})}
|
||||
{actionBar}
|
||||
{this.props.layout === Layout.IRC && (
|
||||
<>
|
||||
|
@@ -163,6 +163,7 @@ export default class ReplyTile extends React.PureComponent<IProps> {
|
||||
highlights: this.props.highlights,
|
||||
highlightLink: this.props.highlightLink,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
showHiddenEvents: false,
|
||||
},
|
||||
false /* showHiddenEvents shouldn't be relevant */,
|
||||
)}
|
||||
|
@@ -26,7 +26,6 @@ import { TimelineRenderingType } from "../contexts/RoomContext";
|
||||
import MessageEvent from "../components/views/messages/MessageEvent";
|
||||
import LegacyCallEvent from "../components/views/messages/LegacyCallEvent";
|
||||
import { CallEvent } from "../components/views/messages/CallEvent";
|
||||
import TextualEvent from "../components/views/messages/TextualEvent";
|
||||
import EncryptionEvent from "../components/views/messages/EncryptionEvent";
|
||||
import { RoomPredecessorTile } from "../components/views/messages/RoomPredecessorTile";
|
||||
import RoomAvatarEvent from "../components/views/messages/RoomAvatarEvent";
|
||||
@@ -44,6 +43,8 @@ import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
|
||||
import { ElementCall } from "../models/Call";
|
||||
import { type IBodyProps } from "../components/views/messages/IBodyProps";
|
||||
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
|
||||
export interface EventTileTypeProps
|
||||
@@ -67,6 +68,7 @@ export interface EventTileTypeProps
|
||||
maxImageHeight?: number; // pixels
|
||||
overrideBodyTypes?: Record<string, React.ComponentType<IBodyProps>>;
|
||||
overrideEventTypes?: Record<string, React.ComponentType<IBodyProps>>;
|
||||
showHiddenEvents: boolean;
|
||||
}
|
||||
|
||||
type FactoryProps = Omit<EventTileTypeProps, "ref">;
|
||||
@@ -77,7 +79,10 @@ const LegacyCallEventFactory: Factory<FactoryProps & { callEventGrouper: LegacyC
|
||||
<LegacyCallEvent 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 HiddenEventFactory: Factory = (ref, props) => <HiddenBody ref={ref} {...props} />;
|
||||
|
||||
@@ -252,12 +257,11 @@ export function pickFactory(
|
||||
export function renderTile(
|
||||
renderType: TimelineRenderingType,
|
||||
props: EventTileTypeProps,
|
||||
showHiddenEvents: boolean,
|
||||
cli?: MatrixClient,
|
||||
): Optional<JSX.Element> {
|
||||
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 we don't have a factory for this event, attempt
|
||||
// to find a custom component that can render it.
|
||||
@@ -286,6 +290,7 @@ export function renderTile(
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
timestamp,
|
||||
inhibitInteraction,
|
||||
showHiddenEvents,
|
||||
} = props;
|
||||
|
||||
switch (renderType) {
|
||||
@@ -309,6 +314,7 @@ export function renderTile(
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
permalinkCreator,
|
||||
inhibitInteraction,
|
||||
showHiddenEvents,
|
||||
}),
|
||||
);
|
||||
default:
|
||||
@@ -332,6 +338,7 @@ export function renderTile(
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
timestamp,
|
||||
inhibitInteraction,
|
||||
showHiddenEvents,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -394,6 +401,7 @@ export function renderReplyTile(
|
||||
getRelationsForEvent,
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
permalinkCreator,
|
||||
showHiddenEvents,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
23
src/shared-components/MockViewModel.ts
Normal file
23
src/shared-components/MockViewModel.ts
Normal 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;
|
||||
}
|
||||
}
|
23
src/shared-components/ViewModel.ts
Normal file
23
src/shared-components/ViewModel.ts
Normal 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;
|
||||
}
|
@@ -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({});
|
@@ -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();
|
||||
});
|
||||
});
|
@@ -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>;
|
||||
}
|
@@ -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>
|
||||
`;
|
8
src/shared-components/event-tiles/TextualEvent/index.ts
Normal file
8
src/shared-components/event-tiles/TextualEvent/index.ts
Normal 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";
|
21
src/shared-components/useViewModel.ts
Normal file
21
src/shared-components/useViewModel.ts
Normal 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);
|
||||
}
|
57
src/viewmodels/ViewModelSubscriptions.ts
Normal file
57
src/viewmodels/ViewModelSubscriptions.ts
Normal 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;
|
||||
}
|
||||
}
|
49
src/viewmodels/event-tiles/TextualEventViewModel.ts
Normal file
49
src/viewmodels/event-tiles/TextualEventViewModel.ts
Normal 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();
|
||||
};
|
||||
}
|
@@ -17,7 +17,8 @@
|
||||
"lib": ["es2022", "es2024.promise", "dom", "dom.iterable"],
|
||||
"strict": true,
|
||||
"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": [
|
||||
@@ -26,7 +27,8 @@
|
||||
"./src/**/*.tsx",
|
||||
"./test/**/*.ts",
|
||||
"./test/**/*.tsx",
|
||||
"./scripts/*.ts"
|
||||
"./scripts/*.ts",
|
||||
"./declaration.d.ts"
|
||||
],
|
||||
"ts-node": {
|
||||
"files": true,
|
||||
|
Reference in New Issue
Block a user