mirror of
https://github.com/element-hq/element-web.git
synced 2025-12-05 01:10:40 +00:00
Compare commits
34 Commits
f7e6cb6129
...
hs/add-win
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14b0303ef8 | ||
|
|
1446550db4 | ||
|
|
ced693c256 | ||
|
|
3ffcaba20b | ||
|
|
bb0f2d65ab | ||
|
|
3f39741d22 | ||
|
|
9f7359e0a4 | ||
|
|
9eb5e8965a | ||
|
|
6d2f1c2e9a | ||
|
|
f44308b9a9 | ||
|
|
bba40ca706 | ||
|
|
706b33fcf4 | ||
|
|
66e73818a8 | ||
|
|
d97d999ef2 | ||
|
|
294857209d | ||
|
|
391bd15258 | ||
|
|
42edbab715 | ||
|
|
5aee224169 | ||
|
|
ff986e4317 | ||
|
|
c6d4f38a04 | ||
|
|
62f62601ef | ||
|
|
e60a68ea1a | ||
|
|
ec13bdc910 | ||
|
|
9136d841ee | ||
|
|
f740dc3829 | ||
|
|
ce428b5e2d | ||
|
|
757e4e1395 | ||
|
|
1b2d9b392c | ||
|
|
1c5bc4a7be | ||
|
|
1ed3f205f3 | ||
|
|
afab6c29dc | ||
|
|
4d81b36270 | ||
|
|
3281a4128f | ||
|
|
213a191b8c |
@@ -81,7 +81,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@element-hq/element-web-module-api": "1.0.0",
|
||||
"@element-hq/element-web-module-api": "1.2.0",
|
||||
"@fontsource/inconsolata": "^5",
|
||||
"@fontsource/inter": "^5",
|
||||
"@formatjs/intl-segmenter": "^11.5.7",
|
||||
|
||||
43
playwright/e2e/modules/brand.spec.ts
Normal file
43
playwright/e2e/modules/brand.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
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 Page } from "@playwright/test";
|
||||
|
||||
import { test, expect } from "../../element-web-test";
|
||||
|
||||
test.describe("Brand API", () => {
|
||||
test.use({
|
||||
displayName: "Manny",
|
||||
botCreateOpts: {
|
||||
autoAcceptInvites: true,
|
||||
},
|
||||
config: {
|
||||
modules: ["/modules/brand-module.js"],
|
||||
},
|
||||
page: async ({ page }, use) => {
|
||||
await page.route("/modules/brand-module.js", async (route) => {
|
||||
await route.fulfill({ path: "playwright/sample-files/brand-module.js" });
|
||||
});
|
||||
await use(page);
|
||||
},
|
||||
room: async ({ page, app, user, bot }, use) => {
|
||||
const roomId = await app.client.createRoom({ name: "TestRoom", invite:[ bot.credentials.userId ] });
|
||||
await use({ roomId });
|
||||
},
|
||||
});
|
||||
test.describe("basic functionality", () => {
|
||||
test(
|
||||
"should replace the standard window title",
|
||||
async ({ page, room, app , user, bot}) => {
|
||||
await page.goto(`/#/home`);
|
||||
// Default title
|
||||
expect(await page.title()).toEqual("MyBrand | OK | notifs=undefined | notifsenabled=undefined | roomId=undefined | roomName=undefined");
|
||||
await app.viewRoomById(room.roomId);
|
||||
expect(await page.title()).toEqual(`MyBrand | OK | notifs=undefined | notifsenabled=undefined | roomId=${room.roomId} | roomName=TestRoom`);
|
||||
},
|
||||
);
|
||||
});
|
||||
112
playwright/e2e/modules/custom-component.spec.ts
Normal file
112
playwright/e2e/modules/custom-component.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
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 Page } from "@playwright/test";
|
||||
|
||||
import { test, expect } from "../../element-web-test";
|
||||
|
||||
const screenshotOptions = (page: Page) => ({
|
||||
mask: [page.locator(".mx_MessageTimestamp")],
|
||||
// Hide the jump to bottom button in the timeline to avoid flakiness
|
||||
// Exclude timestamp and read marker from snapshot
|
||||
css: `
|
||||
.mx_JumpToBottomButton {
|
||||
display: none !important;
|
||||
}
|
||||
.mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
});
|
||||
test.describe("Custom Component API", () => {
|
||||
test.use({
|
||||
displayName: "Manny",
|
||||
config: {
|
||||
modules: ["/modules/custom-component-module.js"],
|
||||
},
|
||||
page: async ({ page }, use) => {
|
||||
await page.route("/modules/custom-component-module.js", async (route) => {
|
||||
await route.fulfill({ path: "playwright/sample-files/custom-component-module.js" });
|
||||
});
|
||||
await use(page);
|
||||
},
|
||||
room: async ({ page, app, user, bot }, use) => {
|
||||
const roomId = await app.client.createRoom({ name: "TestRoom" });
|
||||
await use({ roomId });
|
||||
},
|
||||
});
|
||||
test.describe("basic functionality", () => {
|
||||
test(
|
||||
"should replace the render method of a textual event",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
await app.client.sendMessage(room.roomId, "Simple message");
|
||||
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
|
||||
"custom-component-tile.png",
|
||||
screenshotOptions(page),
|
||||
);
|
||||
},
|
||||
);
|
||||
test(
|
||||
"should fall through if one module does not render a component",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
await app.client.sendMessage(room.roomId, "Fall through here");
|
||||
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
|
||||
"custom-component-tile-fall-through.png",
|
||||
screenshotOptions(page),
|
||||
);
|
||||
},
|
||||
);
|
||||
test(
|
||||
"should render the original content of a textual event conditionally",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
await app.client.sendMessage(room.roomId, "Do not replace me");
|
||||
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
|
||||
"custom-component-tile-original.png",
|
||||
screenshotOptions(page),
|
||||
);
|
||||
},
|
||||
);
|
||||
test("should disallow editing when the allowEditingEvent hint is set to false", async ({ page, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
await app.client.sendMessage(room.roomId, "Do not show edits");
|
||||
await page.getByText("Do not show edits").hover();
|
||||
await expect(
|
||||
await page.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "Edit" }),
|
||||
).not.toBeVisible();
|
||||
});
|
||||
test(
|
||||
"should render the next registered component if the filter function throws",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
await app.client.sendMessage(room.roomId, "Crash the filter!");
|
||||
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
|
||||
"custom-component-crash-handle-filter.png",
|
||||
screenshotOptions(page),
|
||||
);
|
||||
},
|
||||
);
|
||||
test(
|
||||
"should render original component if the render function throws",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, room, app }) => {
|
||||
await app.viewRoomById(room.roomId);
|
||||
await app.client.sendMessage(room.roomId, "Crash the renderer!");
|
||||
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
|
||||
"custom-component-crash-handle-renderer.png",
|
||||
screenshotOptions(page),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
24
playwright/sample-files/brand-module.js
Normal file
24
playwright/sample-files/brand-module.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
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 default class CustomBrandModule {
|
||||
static moduleApiVersion = "^1.2.0";
|
||||
/**
|
||||
*
|
||||
* @param {import("@element-hq/element-web-module-api").Api} api
|
||||
*/
|
||||
constructor(api) {
|
||||
this.api = api;
|
||||
this.api.brand.registerTitleRenderer(({errorDidOccur, notificationCount, notificationsEnabled, roomId, roomName}) => {
|
||||
return `MyBrand | ${errorDidOccur ? "ERROR" : "OK"} | notifs=${notificationCount} | notifsenabled=${notificationsEnabled} | roomId=${roomId} | roomName=${roomName}`
|
||||
});
|
||||
this.api.brand.registerFaviconRenderer(() => {
|
||||
return `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAGMUExURQAAAHZNgHhuiXZQgXqYlX3Kon3IonZOgJPQsXrHoF0AGHmglnVLf7POxIOGlpjStXpwi5TRsoXLqHt5jY7OrorNq4fMqZ//4HZTgnZPgXuqmXjUonXEnFK4hHZNgHZNgHZMgHZMgHpqiY7Cq5LRsZDPr4rNq2vBlXZNgHZNgHZNgHZPgJTRsozOrHZKf3ZNgHZNgICUl5PQsYHKpVgAFXZNgHZNgI3PrZTRsofNqWkAXHZNgHZNgI3OrZTRsoPMp28CanZNgHZNgIzOrYmen4/Rr0/BhXU4enZNgHZNgIzOrImhoHZQgXhXg3lriXZPgXZNgJrTtorIqnZNgHZMgHZOgHZNgHZNgIvNrIvRrXE0d3ZNgHZNgHZNgHZNgITLp4/Pr4/ProPKpnZNgHZNgHZNgHZNgGK+j1y8i3ZLf3dQgY2tpZfUtZbStH1tjH91j5LCrZbTtJTKsJTMsZrOtZbQs5XSs4yppH1sjIWNmZXOspfStIJ9k3VIfpvTt5vVt3ZMgHZNgJjStf///3HnC34AAABpdFJOUwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAECQkJAwgFI6TBwL+oUAQEVTpr82QDKxpu0xcBIxNt7zADPSZt4CADOSRt/ogCASgsbfL78D0BGP51lawLASVlUBjK6DMgIKObGLJ6B0cEAwV2SrsAAAABYktHRIP8tM/SAAAAB3RJTUUH6QYXDRw74LVdRgAAAMhJREFUGNNjYMAAjEzMLKxsQMAIAiABdkkpaRlZDk4uRjl5sICCopKyiqqaOreGphZIgEc7Mys7J1dHl1dP3wAkwGeYl19QmJtrZGxiagYS4DcvKs4tKc21sLSytgEL2JYVlldUVtnZOzg6gQWcq6trarNcXN3cPTzBAl519d4+Db5+Av4GAVCBwKDgxoaQ0LBwsKGCEU3VkVHRjTFOjLFxIAGh+ITEJGHr5JTUsDSwGSKi6Rli4ozyYYxhEKeD/MUmwQgFmJ4HALFlKaeL1fmEAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI1LTA2LTIzVDEzOjI4OjU5KzAwOjAwkTuBLgAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNS0wNi0yM1QxMzoyODo1OSswMDowMOBmOZIAAAAodEVYdGRhdGU6dGltZXN0YW1wADIwMjUtMDYtMjNUMTM6Mjg6NTkrMDA6MDC3cxhNAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAABJRU5ErkJggg==`;
|
||||
})
|
||||
}
|
||||
async load() {}
|
||||
}
|
||||
55
playwright/sample-files/custom-component-module.js
Normal file
55
playwright/sample-files/custom-component-module.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
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 default class CustomComponentModule {
|
||||
static moduleApiVersion = "^1.2.0";
|
||||
constructor(api) {
|
||||
this.api = api;
|
||||
this.api.customComponents.registerMessageRenderer(
|
||||
(evt) => evt.content.body === "Do not show edits",
|
||||
(_props, originalComponent) => {
|
||||
return originalComponent();
|
||||
},
|
||||
{ allowEditingEvent: false },
|
||||
);
|
||||
this.api.customComponents.registerMessageRenderer(
|
||||
(evt) => evt.content.body === "Fall through here",
|
||||
(props) => {
|
||||
const body = props.mxEvent.content.body;
|
||||
return `Fallthrough text for ${body}`;
|
||||
},
|
||||
);
|
||||
this.api.customComponents.registerMessageRenderer(
|
||||
(evt) => {
|
||||
if (evt.content.body === "Crash the filter!") {
|
||||
throw new Error("Fail test!");
|
||||
}
|
||||
return false;
|
||||
},
|
||||
() => {
|
||||
return `Should not render!`;
|
||||
},
|
||||
);
|
||||
this.api.customComponents.registerMessageRenderer(
|
||||
(evt) => evt.content.body === "Crash the renderer!",
|
||||
() => {
|
||||
throw new Error("Fail test!");
|
||||
},
|
||||
);
|
||||
// Order is specific here to avoid this overriding the other renderers
|
||||
this.api.customComponents.registerMessageRenderer("m.room.message", (props, originalComponent) => {
|
||||
const body = props.mxEvent.content.body;
|
||||
if (body === "Do not replace me") {
|
||||
return originalComponent();
|
||||
} else if (body === "Fall through here") {
|
||||
return null;
|
||||
}
|
||||
return `Custom text for ${body}`;
|
||||
});
|
||||
}
|
||||
async load() {}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
@@ -28,6 +28,7 @@ import { TooltipProvider } from "@vector-im/compound-web";
|
||||
// what-input helps improve keyboard accessibility
|
||||
import "what-input";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
import { type TitleRenderOptions } from "@element-hq/element-web-module-api";
|
||||
|
||||
import PosthogTrackers from "../../PosthogTrackers";
|
||||
import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
|
||||
@@ -141,6 +142,7 @@ import { type OpenForwardDialogPayload } from "../../dispatcher/payloads/OpenFor
|
||||
import { ShareFormat, type SharePayload } from "../../dispatcher/payloads/SharePayload";
|
||||
import Markdown from "../../Markdown";
|
||||
import { sanitizeHtmlParams } from "../../Linkify";
|
||||
import moduleApi from "../../modules/Api";
|
||||
|
||||
// legacy export
|
||||
export { default as Views } from "../../Views";
|
||||
@@ -227,7 +229,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
private tokenLogin?: boolean;
|
||||
// What to focus on next component update, if anything
|
||||
private focusNext: FocusNextType;
|
||||
private subTitleStatus: string;
|
||||
private subTitleState: TitleRenderOptions;
|
||||
private prevWindowWidth: number;
|
||||
|
||||
private readonly loggedInView = createRef<LoggedInViewType>();
|
||||
@@ -283,7 +285,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
|
||||
// object field used for tracking the status info appended to the title tag.
|
||||
// we don't do it as react state as i'm scared about triggering needless react refreshes.
|
||||
this.subTitleStatus = "";
|
||||
this.subTitleState = {};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1505,7 +1507,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
collapseLhs: false,
|
||||
currentRoomId: null,
|
||||
});
|
||||
this.subTitleStatus = "";
|
||||
this.subTitleState = {};
|
||||
this.setPageSubtitle();
|
||||
this.stores.onLoggedOut();
|
||||
}
|
||||
@@ -1521,7 +1523,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
collapseLhs: false,
|
||||
currentRoomId: null,
|
||||
});
|
||||
this.subTitleStatus = "";
|
||||
this.subTitleState = {};
|
||||
this.setPageSubtitle();
|
||||
}
|
||||
|
||||
@@ -1992,18 +1994,44 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
});
|
||||
}
|
||||
|
||||
private setPageSubtitle(subtitle = ""): void {
|
||||
private setPageSubtitle(): void {
|
||||
let roomName: string | undefined;
|
||||
if (this.state.currentRoomId) {
|
||||
const client = MatrixClientPeg.get();
|
||||
const room = client?.getRoom(this.state.currentRoomId);
|
||||
if (room) {
|
||||
subtitle = `${this.subTitleStatus} | ${room.name} ${subtitle}`;
|
||||
}
|
||||
} else {
|
||||
subtitle = `${this.subTitleStatus} ${subtitle}`;
|
||||
roomName = client?.getRoom(this.state.currentRoomId)?.name;
|
||||
}
|
||||
|
||||
const title = `${SdkConfig.get().brand} ${subtitle}`;
|
||||
let title = moduleApi.brand.renderTitle({
|
||||
...this.subTitleState,
|
||||
roomName,
|
||||
roomId: this.state.currentRoomId ?? undefined,
|
||||
});
|
||||
|
||||
if (title === undefined) {
|
||||
// No module API implemented, fallback
|
||||
let subTitleStatus = "";
|
||||
|
||||
if (this.subTitleState.errorDidOccur) {
|
||||
subTitleStatus += `[${_t("common|offline")}] `;
|
||||
}
|
||||
|
||||
if ((this.subTitleState.notificationCount ?? 0) > 0) {
|
||||
subTitleStatus += `[${this.subTitleState.notificationCount}]`;
|
||||
} else if (this.subTitleState.notificationsEnabled) {
|
||||
subTitleStatus += `*`;
|
||||
}
|
||||
|
||||
let subtitle;
|
||||
if (this.state.currentRoomId) {
|
||||
if (roomName) {
|
||||
subtitle = `${subTitleStatus} | ${roomName}`;
|
||||
}
|
||||
} else {
|
||||
subtitle = `${subTitleStatus}`;
|
||||
}
|
||||
|
||||
title = `${SdkConfig.get().brand} ${subtitle}`;
|
||||
}
|
||||
|
||||
if (document.title !== title) {
|
||||
document.title = title;
|
||||
@@ -2011,22 +2039,20 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
|
||||
}
|
||||
|
||||
private onUpdateStatusIndicator = (notificationState: SummarizedNotificationState, state: SyncState): void => {
|
||||
const numUnreadRooms = notificationState.numUnreadStates; // we know that states === rooms here
|
||||
console.log("onUpdateStatusIndicator");
|
||||
const notificationCount = notificationState.numUnreadStates; // we know that states === rooms here
|
||||
const platform = PlatformPeg.get();
|
||||
|
||||
if (PlatformPeg.get()) {
|
||||
PlatformPeg.get()!.setErrorStatus(state === SyncState.Error);
|
||||
PlatformPeg.get()!.setNotificationCount(numUnreadRooms);
|
||||
if (platform) {
|
||||
platform.setErrorStatus(state === SyncState.Error);
|
||||
platform.setNotificationCount(notificationCount);
|
||||
}
|
||||
|
||||
this.subTitleStatus = "";
|
||||
if (state === SyncState.Error) {
|
||||
this.subTitleStatus += `[${_t("common|offline")}] `;
|
||||
}
|
||||
if (numUnreadRooms > 0) {
|
||||
this.subTitleStatus += `[${numUnreadRooms}]`;
|
||||
} else if (notificationState.level >= NotificationLevel.Activity) {
|
||||
this.subTitleStatus += `*`;
|
||||
}
|
||||
this.subTitleState = {
|
||||
errorDidOccur: state === SyncState.Error,
|
||||
notificationCount,
|
||||
notificationsEnabled: notificationState.level >= NotificationLevel.Activity,
|
||||
};
|
||||
|
||||
this.setPageSubtitle();
|
||||
};
|
||||
|
||||
@@ -43,6 +43,7 @@ import ViewSourceEvent from "../components/views/messages/ViewSourceEvent";
|
||||
import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
|
||||
import { ElementCall } from "../models/Call";
|
||||
import { type IBodyProps } from "../components/views/messages/IBodyProps";
|
||||
import ModuleApi from "../modules/Api";
|
||||
|
||||
// Subset of EventTile's IProps plus some mixins
|
||||
export interface EventTileTypeProps
|
||||
@@ -257,7 +258,14 @@ export function renderTile(
|
||||
cli = cli ?? MatrixClientPeg.safeGet(); // because param defaults don't do the correct thing
|
||||
|
||||
const factory = pickFactory(props.mxEvent, cli, showHiddenEvents);
|
||||
if (!factory) return undefined;
|
||||
if (!factory) {
|
||||
// If we don't have a factory for this event, attempt
|
||||
// to find a custom component that can render it.
|
||||
// Will return null if no custom component can render it.
|
||||
return ModuleApi.customComponents.renderMessage({
|
||||
mxEvent: props.mxEvent,
|
||||
});
|
||||
}
|
||||
|
||||
// Note that we split off the ones we actually care about here just to be sure that we're
|
||||
// not going to accidentally send things we shouldn't from lazy callers. Eg: EventTile's
|
||||
@@ -284,36 +292,48 @@ export function renderTile(
|
||||
case TimelineRenderingType.File:
|
||||
case TimelineRenderingType.Notification:
|
||||
case TimelineRenderingType.Thread:
|
||||
// We only want a subset of props, so we don't end up causing issues for downstream components.
|
||||
return factory(props.ref, {
|
||||
mxEvent,
|
||||
highlights,
|
||||
highlightLink,
|
||||
showUrlPreview,
|
||||
editState,
|
||||
replacingEventId,
|
||||
getRelationsForEvent,
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
permalinkCreator,
|
||||
inhibitInteraction,
|
||||
});
|
||||
return ModuleApi.customComponents.renderMessage(
|
||||
{
|
||||
mxEvent: props.mxEvent,
|
||||
},
|
||||
(origProps) =>
|
||||
factory(props.ref, {
|
||||
// We only want a subset of props, so we don't end up causing issues for downstream components.
|
||||
mxEvent,
|
||||
highlights,
|
||||
highlightLink,
|
||||
showUrlPreview: origProps?.showUrlPreview ?? showUrlPreview,
|
||||
editState,
|
||||
replacingEventId,
|
||||
getRelationsForEvent,
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
permalinkCreator,
|
||||
inhibitInteraction,
|
||||
}),
|
||||
);
|
||||
default:
|
||||
// NEARLY ALL THE OPTIONS!
|
||||
return factory(ref, {
|
||||
mxEvent,
|
||||
forExport,
|
||||
replacingEventId,
|
||||
editState,
|
||||
highlights,
|
||||
highlightLink,
|
||||
showUrlPreview,
|
||||
permalinkCreator,
|
||||
callEventGrouper,
|
||||
getRelationsForEvent,
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
timestamp,
|
||||
inhibitInteraction,
|
||||
});
|
||||
return ModuleApi.customComponents.renderMessage(
|
||||
{
|
||||
mxEvent: props.mxEvent,
|
||||
},
|
||||
(origProps) =>
|
||||
factory(ref, {
|
||||
// NEARLY ALL THE OPTIONS!
|
||||
mxEvent,
|
||||
forExport,
|
||||
replacingEventId,
|
||||
editState,
|
||||
highlights,
|
||||
highlightLink,
|
||||
showUrlPreview: origProps?.showUrlPreview ?? showUrlPreview,
|
||||
permalinkCreator,
|
||||
callEventGrouper,
|
||||
getRelationsForEvent,
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
timestamp,
|
||||
inhibitInteraction,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,7 +352,14 @@ export function renderReplyTile(
|
||||
cli = cli ?? MatrixClientPeg.safeGet(); // because param defaults don't do the correct thing
|
||||
|
||||
const factory = pickFactory(props.mxEvent, cli, showHiddenEvents);
|
||||
if (!factory) return undefined;
|
||||
if (!factory) {
|
||||
// If we don't have a factory for this event, attempt
|
||||
// to find a custom component that can render it.
|
||||
// Will return null if no custom component can render it.
|
||||
return ModuleApi.customComponents.renderMessage({
|
||||
mxEvent: props.mxEvent,
|
||||
});
|
||||
}
|
||||
|
||||
// See renderTile() for why we split off so much
|
||||
const {
|
||||
@@ -350,19 +377,25 @@ export function renderReplyTile(
|
||||
permalinkCreator,
|
||||
} = props;
|
||||
|
||||
return factory(ref, {
|
||||
mxEvent,
|
||||
highlights,
|
||||
highlightLink,
|
||||
showUrlPreview,
|
||||
overrideBodyTypes,
|
||||
overrideEventTypes,
|
||||
replacingEventId,
|
||||
maxImageHeight,
|
||||
getRelationsForEvent,
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
permalinkCreator,
|
||||
});
|
||||
return ModuleApi.customComponents.renderMessage(
|
||||
{
|
||||
mxEvent: props.mxEvent,
|
||||
},
|
||||
(origProps) =>
|
||||
factory(ref, {
|
||||
mxEvent,
|
||||
highlights,
|
||||
highlightLink,
|
||||
showUrlPreview: origProps?.showUrlPreview ?? showUrlPreview,
|
||||
overrideBodyTypes,
|
||||
overrideEventTypes,
|
||||
replacingEventId,
|
||||
maxImageHeight,
|
||||
getRelationsForEvent,
|
||||
isSeeingThroughMessageHiddenForModeration,
|
||||
permalinkCreator,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// XXX: this'll eventually be dynamic based on the fields once we have extensible event types
|
||||
@@ -386,6 +419,12 @@ export function haveRendererForEvent(
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check to see if we have any hints for this message, which indicates
|
||||
// there is a custom renderer for the event.
|
||||
if (ModuleApi.customComponents.getHintsForMessage(mxEvent)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// No tile for replacement events since they update the original tile
|
||||
if (mxEvent.isRelation(RelationType.Replace)) return false;
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { type Api, type RuntimeModuleConstructor } from "@element-hq/element-web-module-api";
|
||||
|
||||
import type { Api, RuntimeModuleConstructor } from "@element-hq/element-web-module-api";
|
||||
import { ModuleRunner } from "./ModuleRunner.ts";
|
||||
import AliasCustomisations from "../customisations/Alias.ts";
|
||||
import { RoomListCustomisations } from "../customisations/RoomList.ts";
|
||||
@@ -21,6 +21,8 @@ import { WidgetPermissionCustomisations } from "../customisations/WidgetPermissi
|
||||
import { WidgetVariableCustomisations } from "../customisations/WidgetVariables.ts";
|
||||
import { ConfigApi } from "./ConfigApi.ts";
|
||||
import { I18nApi } from "./I18nApi.ts";
|
||||
import { CustomComponentsApi } from "./customComponentApi.ts";
|
||||
import { BrandApi } from "./brandApi.ts";
|
||||
|
||||
const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
|
||||
let used = false;
|
||||
@@ -58,7 +60,9 @@ class ModuleApi implements Api {
|
||||
|
||||
public readonly config = new ConfigApi();
|
||||
public readonly i18n = new I18nApi();
|
||||
public readonly customComponents = new CustomComponentsApi();
|
||||
public readonly rootNode = document.getElementById("matrixchat")!;
|
||||
public readonly brand = new BrandApi();
|
||||
|
||||
public createRoot(element: Element): Root {
|
||||
return createRoot(element);
|
||||
|
||||
36
src/modules/brandApi.ts
Normal file
36
src/modules/brandApi.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
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 {
|
||||
BrandApi as IBrandApi,
|
||||
TitleRenderFunction,
|
||||
TitleRenderOptions
|
||||
} from "@element-hq/element-web-module-api";
|
||||
|
||||
|
||||
export class BrandApi implements IBrandApi {
|
||||
private registeredTitleFunction?: TitleRenderFunction;
|
||||
|
||||
public registerTitleRenderer(
|
||||
func: TitleRenderFunction
|
||||
): void {
|
||||
if (this.registeredTitleFunction) {
|
||||
throw Error('A custom title rendering function has already been registered');
|
||||
}
|
||||
this.registeredTitleFunction = func;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the title text if a module has generated one, otherwise
|
||||
* this returns undefined.
|
||||
* @param opts Options to pass to the render function.
|
||||
* @returns Title text, or undefined.
|
||||
*/
|
||||
public renderTitle(opts: TitleRenderOptions): string|undefined {
|
||||
return this.registeredTitleFunction?.(opts);
|
||||
}
|
||||
}
|
||||
126
src/modules/customComponentApi.ts
Normal file
126
src/modules/customComponentApi.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
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 MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import type {
|
||||
CustomComponentsApi as ICustomComponentsApi,
|
||||
CustomMessageRenderFunction,
|
||||
CustomMessageComponentProps as ModuleCustomMessageComponentProps,
|
||||
OriginalComponentProps,
|
||||
CustomMessageRenderHints,
|
||||
MatrixEvent as ModuleMatrixEvent,
|
||||
} from "@element-hq/element-web-module-api";
|
||||
import type React from "react";
|
||||
|
||||
type EventTypeOrFilter = Parameters<ICustomComponentsApi["registerMessageRenderer"]>[0];
|
||||
|
||||
type EventRenderer = {
|
||||
eventTypeOrFilter: EventTypeOrFilter;
|
||||
renderer: CustomMessageRenderFunction;
|
||||
hints: CustomMessageRenderHints;
|
||||
};
|
||||
|
||||
interface CustomMessageComponentProps extends Omit<ModuleCustomMessageComponentProps, "mxEvent"> {
|
||||
mxEvent: MatrixEvent;
|
||||
}
|
||||
|
||||
export class CustomComponentsApi implements ICustomComponentsApi {
|
||||
/**
|
||||
* Convert a matrix-js-sdk event into a ModuleMatrixEvent.
|
||||
* @param mxEvent
|
||||
* @returns An event object, or `null` if the event was not a message event.
|
||||
*/
|
||||
private static getModuleMatrixEvent(mxEvent: MatrixEvent): ModuleMatrixEvent | null {
|
||||
const eventId = mxEvent.getId();
|
||||
const roomId = mxEvent.getRoomId();
|
||||
const sender = mxEvent.sender;
|
||||
// Typically we wouldn't expect messages without these keys to be rendered
|
||||
// by the timeline, but for the sake of type safety.
|
||||
if (!eventId || !roomId || !sender) {
|
||||
// Not a message event.
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
content: mxEvent.getContent(),
|
||||
eventId,
|
||||
originServerTs: mxEvent.getTs(),
|
||||
roomId,
|
||||
sender: sender.userId,
|
||||
stateKey: mxEvent.getStateKey(),
|
||||
type: mxEvent.getType(),
|
||||
unsigned: mxEvent.getUnsigned(),
|
||||
};
|
||||
}
|
||||
|
||||
private readonly registeredMessageRenderers: EventRenderer[] = [];
|
||||
|
||||
public registerMessageRenderer(
|
||||
eventTypeOrFilter: EventTypeOrFilter,
|
||||
renderer: CustomMessageRenderFunction,
|
||||
hints: CustomMessageRenderHints = {},
|
||||
): void {
|
||||
this.registeredMessageRenderers.push({ eventTypeOrFilter: eventTypeOrFilter, renderer, hints });
|
||||
}
|
||||
/**
|
||||
* Select the correct renderer based on the event information.
|
||||
* @param mxEvent The message event being rendered.
|
||||
* @returns The registered renderer.
|
||||
*/
|
||||
private selectRenderer(mxEvent: ModuleMatrixEvent): EventRenderer | undefined {
|
||||
return this.registeredMessageRenderers.find((renderer) => {
|
||||
if (typeof renderer.eventTypeOrFilter === "string") {
|
||||
return renderer.eventTypeOrFilter === mxEvent.type;
|
||||
} else {
|
||||
try {
|
||||
return renderer.eventTypeOrFilter(mxEvent);
|
||||
} catch (ex) {
|
||||
logger.warn("Message renderer failed to process filter", ex);
|
||||
return false; // Skip erroring renderers.
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the component for a message event.
|
||||
* @param props Props to be passed to the custom renderer.
|
||||
* @param originalComponent Function that will be rendered if no custom renderers are present, or as a child of a custom component.
|
||||
* @returns A component if a custom renderer exists, or originalComponent returns a value. Otherwise null.
|
||||
*/
|
||||
public renderMessage(
|
||||
props: CustomMessageComponentProps,
|
||||
originalComponent?: (props?: OriginalComponentProps) => React.JSX.Element,
|
||||
): React.JSX.Element | null {
|
||||
const moduleEv = CustomComponentsApi.getModuleMatrixEvent(props.mxEvent);
|
||||
const renderer = moduleEv && this.selectRenderer(moduleEv);
|
||||
if (renderer) {
|
||||
try {
|
||||
return renderer.renderer({ ...props, mxEvent: moduleEv }, originalComponent);
|
||||
} catch (ex) {
|
||||
logger.warn("Message renderer failed to render", ex);
|
||||
// Fall through to original component. If the module encounters an error we still want to display messages to the user!
|
||||
}
|
||||
}
|
||||
return originalComponent?.() ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hints about an message before rendering it.
|
||||
* @param mxEvent The message event being rendered.
|
||||
* @returns A component if a custom renderer exists, or originalComponent returns a value. Otherwise null.
|
||||
*/
|
||||
public getHintsForMessage(mxEvent: MatrixEvent): CustomMessageRenderHints | null {
|
||||
const moduleEv = CustomComponentsApi.getModuleMatrixEvent(mxEvent);
|
||||
const renderer = moduleEv && this.selectRenderer(moduleEv);
|
||||
if (renderer) {
|
||||
return renderer.hints;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,7 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient<EmptyObject
|
||||
// pass SyncState.Error.
|
||||
this.emitUpdateIfStateChanged(SyncState.Syncing, false);
|
||||
});
|
||||
this.emitUpdateIfStateChanged(SyncState.Syncing, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,6 +108,7 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient<EmptyObject
|
||||
* @internal public for test
|
||||
*/
|
||||
public emitUpdateIfStateChanged = (state: SyncState, forceEmit: boolean): void => {
|
||||
console.log("emitUpdateIfStateChanged", this.matrixClient!);
|
||||
if (!this.matrixClient) return;
|
||||
// Only count visible rooms to not torment the user with notification counts in rooms they can't see.
|
||||
// This will include highlights from the previous version of the room internally
|
||||
@@ -134,6 +136,8 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient<EmptyObject
|
||||
) {
|
||||
this._globalState = globalState;
|
||||
this.emit(UPDATE_STATUS_INDICATOR, globalState, state);
|
||||
} else {
|
||||
console.log("skipping emit");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import { type TimelineRenderingType } from "../contexts/RoomContext";
|
||||
import { launchPollEditor } from "../components/views/messages/MPollBody";
|
||||
import { Action } from "../dispatcher/actions";
|
||||
import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
|
||||
import ModuleApi from "../modules/Api";
|
||||
|
||||
/**
|
||||
* Returns whether an event should allow actions like reply, reactions, edit, etc.
|
||||
@@ -77,6 +78,10 @@ export function canEditContent(matrixClient: MatrixClient, mxEvent: MatrixEvent)
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ModuleApi.customComponents.getHintsForMessage(mxEvent)?.allowEditingEvent === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { msgtype, body } = mxEvent.getOriginalContent();
|
||||
return (
|
||||
M_POLL_START.matches(mxEvent.getType()) ||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { shouldPolyfill as shouldPolyFillIntlSegmenter } from "@formatjs/intl-se
|
||||
// These are things that can run before the skin loads - be careful not to reference the react-sdk though.
|
||||
import { parseQsFromFragment } from "./url_utils";
|
||||
import "./modernizr";
|
||||
import moduleApi from "../modules/Api";
|
||||
|
||||
// Require common CSS here; this will make webpack process it into bundle.css.
|
||||
// Our own CSS (which is themed) is imported via separate webpack entry points
|
||||
@@ -222,6 +223,12 @@ async function start(): Promise<void> {
|
||||
await loadThemePromise;
|
||||
await loadLanguagePromise;
|
||||
|
||||
// Render the title as early as we can so that the true brand pops up.
|
||||
const moduleTitle = moduleApi.brand.renderTitle({});
|
||||
if (moduleTitle) {
|
||||
document.title = moduleTitle;
|
||||
}
|
||||
|
||||
// We don't care if the log persistence made it through successfully, but we do want to
|
||||
// make sure it had a chance to load before we move on. It's prepared much higher up in
|
||||
// the process, making this the first time we check that it did something.
|
||||
|
||||
@@ -1672,10 +1672,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@element-hq/element-call-embedded/-/element-call-embedded-0.12.2.tgz#b6b6b7df69369b3088960b79591ce1bfd2b84a1a"
|
||||
integrity sha512-2u5/bOARcjc5TFq4929x1R0tvsNbeVA58FBtiW05GlIJCapxzPSOeeGhbqEcJ1TW3/hLGpiKMcw0QwRBQVNzQA==
|
||||
|
||||
"@element-hq/element-web-module-api@1.0.0":
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.0.0.tgz#df09108b0346a44ad2898c603d1a6cda5f50d80b"
|
||||
integrity sha512-FYId5tYgaKvpqAXRXqs0pY4+7/A09bEl1mCxFqlS9jlZOCjlMZVvZuv8spbY8ZN9HaMvuVmx9J00Fn2gCJd0TQ==
|
||||
"@element-hq/element-web-module-api@1.2.0":
|
||||
version "1.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.2.0.tgz#4d91c890a74f808a82759dcb00a8e47dcf131236"
|
||||
integrity sha512-+2fjShcuFLWVWzhRVlveg4MHevcT7XiXie6JB2SIS89FoJWAnsr41eiSbUORAIHndBCrznU8a/lYz9Pf8BXYVA==
|
||||
|
||||
"@element-hq/element-web-playwright-common@^1.1.5":
|
||||
version "1.3.0"
|
||||
|
||||
Reference in New Issue
Block a user