diff --git a/changelog.d/1064.feature b/changelog.d/1064.feature
new file mode 100644
index 000000000..11a5d7898
--- /dev/null
+++ b/changelog.d/1064.feature
@@ -0,0 +1 @@
+Add Element Web/Desktop module for pretty rendering of Hookshot information.
diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md
index 7a2dc5118..85d4c2e93 100644
--- a/docs/SUMMARY.md
+++ b/docs/SUMMARY.md
@@ -31,4 +31,5 @@
- [Workers](./advanced/workers.md)
- [🔒 Encryption](./advanced/encryption.md)
- [🪀 Widgets](./advanced/widgets.md)
+- [Element Web Integration](./advanced/element_module.md)
- [Service Bots](./advanced/service_bots.md)
diff --git a/docs/_site/style.css b/docs/_site/style.css
index 4fe146c79..827c1de2d 100644
--- a/docs/_site/style.css
+++ b/docs/_site/style.css
@@ -71,3 +71,8 @@
content: " "
url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAPCAYAAADtc08vAAAB5klEQVQoU4WSQU7bUBCGZ6wkStogwglwdkikqpEI6/gEhF3SVXoCzA3CCWpOUO9sVvgGeB0jYSlGYodvAFKgrmLk6cwztlKUgHfWe++f///mR/jgO9wfW4g5hfHlxaZruOngqDeaEoIl50hgz2Jvuu7uWgFDH3bqW60HADorHuGvbJF2o8R/ei+yVqD/beQAoR7G7kAe9HvjgG0k4dybfCrQ3/9hoEa3BGCHc1c5OOyNBhridU5k3sResCqy1oGCp5FkvuKpP5UL5Qp2w9gzNwrwpQkD22Vg58beSG/UUKbdzmL3pODSTPjfYlGnFKkcrIBDzv+QPadm/esXHbQ8oBynN3euXWwGT1eBVgJyyLQny+fUaLSbATE1EWlstQbM43eW0UF07yVHvTG7IKdcayVQkIbvkKOZvfxJGu1WVF6URyzi81asEugyo64IKgHJKz8KFOCxWKy1mwYi+Nnir87Zh9IF3spOtVYgbqhnYqW4SHekKBwlYvuBTJPJObfw9SV1OMpjuUYFuI5cNDjB95lkE+U0jmUzio4USN3jas/mrv/mwkakYyXA+R4571tNscPWjSU7YpiWUGe3ESAaSHQh8BQvgG0W76JqHuac8f9v+QpOrQa6BjAoT3KAQJpYbIxHkub/A+GHBVloER6qAAAAAElFTkSuQmCC");
}
+
+.chapter-item:nth-child(17) > a:nth-child(1) > strong:after {
+ content: " "
+ url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAEgUExURQAAAA++jjLWsw29iwAiABG/kBC/kA29iivdxjzdbw29jA28iwAA+w++iRG/iQy8jBC/iQAA/zXkaA/Aiw6+iw29iw29iw29iw29iw29iw+/jA2+jA29iw29iw29iw29iw29iw2+iw69jA29iw29iw29jA2+jA29iw+/jQ2+iw29iw29iwy+jA2+ig29iw69iw29iw29iwy8jA29iwy9igq8igq8iQy9iwu8ijPHnYbexVvSsBjAkAm8iUDLo6Ll0s7y56Tm0x/ClB3Bk33cwb7t4HvbwDHHnMLu4n/cwqHl0sbv5GzXuDTIngi8iWXVtcDu4ULLpDbIn1jSrzLHnaPm01jRr2fVtqDl0sHu4s7y6KLm0kDLpFrSsDXInv///9XgurcAAAAzdFJOUwAAAAAAAAAAAAAAAAAAAAAAAAADKmGDgmIsAiKQ3/ngjyM1y8w1IyICAyr6AwIjNTUCA5FaOyQAAAABYktHRF9z0VEtAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH6QYJDjceb0HqnQAAAPhJREFUGNMVz9lCgkAYBeCfxrIyyha2YistisqWf4CJIMkFMpXUoL3e/zEabs+5ON8BEJbI7p4kK6q2T2oCgLBMDnTDRDQt+5CsCFAjRy1E6vkBYvu4vgrkpI3I7sL7iCK2HAKnNu/jh27y2OOJfgaahbQ/GKbZ0yhmaLigmkH0PJ5M85fZfEFNCRT0X5MiZyxLy9BDGWT0wjItGMuL5M1HBSSTLuaz93w6GX9EwbkKroHMH31m6XDQp2hpcKHz2V759f0T81n7EohTQaPf8I9xaofA2voVpwa+V0GvGxsgbjY6tlWdM3RnqykCiNvkRlMVWXJvyY4I/ypzLOKqXddSAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI1LTA2LTA5VDE0OjU1OjMwKzAwOjAwQ/V/+gAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNS0wNi0wOVQxNDo1NTozMCswMDowMDKox0YAAAAodEVYdGRhdGU6dGltZXN0YW1wADIwMjUtMDYtMDlUMTQ6NTU6MzArMDA6MDBlveaZAAAAAElFTkSuQmCC");
+}
diff --git a/docs/advanced/element_module.md b/docs/advanced/element_module.md
new file mode 100644
index 000000000..6a2a32e80
--- /dev/null
+++ b/docs/advanced/element_module.md
@@ -0,0 +1,20 @@
+# Element Module
+
+
+Element Modules are very much in the early stages of development, and Hookshot is using bleeding edge APIs.
+Please be aware that breakages are likely until the APIs become more stable.
+
+
+Hookshot provides a module that can be used by Element Web / Element Desktop to render
+additional information below an event that has come from Hookshot. You will need to be
+able to edit your Element's `config.json` for this feature to work.
+
+This may be enabled by adding the module's URL to your Element Web `config.json` file.
+See [Element Web's documentation](https://github.com/element-hq/element-web/blob/develop/docs/config.md) for how this works.
+
+The Hookshot module will be found under `/elementModule/index.mjs` on the `widgets` listener. For instance
+if you host your widgets listener on `https://hookshot.example.org/widgets` then the path would be `https://hookshot.example.org/widgets/elementModule/index.mjs`.
+
+At the time of writing, this is supported for a subset of integrations:
+
+- OpenProject: Work package previews.
diff --git a/package.json b/package.json
index 2f2aa68fc..67b35d785 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,9 @@
"node": ">=22"
},
"scripts": {
- "build:web": "vite build",
+ "build:web": "yarn build:web:widget && yarn build:web:elementmodule",
+ "build:web:widget": "vite build",
+ "build:web:elementmodule": "vite --config vite.elementmodule.mjs build",
"build:app": "tsc --project tsconfig.json",
"build:app:rs": "napi build --dts ../src/libRs.d.ts --release ./lib",
"build:app:fix-defs": "ts-node scripts/definitions-fixer.ts src/libRs.d.ts",
@@ -86,6 +88,7 @@
"devDependencies": {
"@babel/core": "^7.26.9",
"@codemirror/lang-javascript": "^6.0.2",
+ "@element-hq/element-web-module-api": "^1.0.0",
"@eslint/eslintrc": "^3.2.0",
"@eslint/js": "^9.15.0",
"@fontsource/inter": "^5.1.0",
@@ -106,8 +109,10 @@
"@types/mime": "^3.0.4",
"@types/mocha": "^10.0.6",
"@types/node": "^22",
+ "@types/react": "^19",
"@types/xml2js": "^0.4.11",
"@uiw/react-codemirror": "^4.12.3",
+ "@vitejs/plugin-react": "^4.3.4",
"busboy": "^1.6.0",
"chai": "^4",
"eslint": "^9.15.0",
@@ -118,13 +123,19 @@
"nyc": "^17.1.0",
"preact": "^10.26.2",
"prettier": "^3.5.3",
+ "react": "^19",
+ "react-dom": "^19",
"rimraf": "6.0.1",
+ "rollup-plugin-external-globals": "^0.13.0",
"sass": "^1.81.0",
+ "styled-components": "^6.1.18",
"testcontainers": "^10.25.0",
"ts-node": "10.9.2",
- "typescript": "^5.7.2",
+ "typescript": "^5.7.3",
"typescript-eslint": "^8.16.0",
- "vite": "^5.4.19",
+ "vite": "^6.1.6",
+ "vite-plugin-dts": "^4.5.4",
+ "vite-plugin-node-polyfills": "^0.23.0",
"vitest": "^3.1.3"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
diff --git a/vite.elementmodule.mjs b/vite.elementmodule.mjs
new file mode 100644
index 000000000..7b3d92baf
--- /dev/null
+++ b/vite.elementmodule.mjs
@@ -0,0 +1,39 @@
+import { resolve } from "node:path";
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+import { nodePolyfills } from "vite-plugin-node-polyfills";
+import externalGlobals from "rollup-plugin-external-globals";
+import dts from "vite-plugin-dts";
+
+export default defineConfig({
+ build: {
+ lib: {
+ entry: resolve("web", "elementModule", "index.tsx"),
+ name: "hookshot-openproject",
+ fileName: "index",
+ formats: ["es"],
+ },
+ outDir: "public/elementModule",
+ target: "esnext",
+ sourcemap: true,
+ rollupOptions: {
+ external: ["react"],
+ },
+ },
+ plugins: [
+ dts({tsconfigPath: resolve("web", "elementModule", "tsconfig.json")}),
+ react(),
+ nodePolyfills({
+ include: ["events"],
+ }),
+ externalGlobals({
+ // Reuse React from the host app
+ react: "window.React",
+ }),
+ ],
+ define: {
+ // Use production mode for the build as it is tested against production builds of Element Web,
+ // this is required for React JSX versions to be compatible.
+ process: { env: { NODE_ENV: "production" } },
+ },
+});
diff --git a/web/elementModule/OpenProject.tsx b/web/elementModule/OpenProject.tsx
new file mode 100644
index 000000000..ee77f1c3e
--- /dev/null
+++ b/web/elementModule/OpenProject.tsx
@@ -0,0 +1,273 @@
+import { styled } from "styled-components";
+import { Button } from "@vector-im/compound-web";
+import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-out";
+export interface OpenProjectContent {
+ "org.matrix.matrix-hookshot.openproject.work_package"?: {
+ id: number;
+ subject: string;
+ description: {
+ plain: string;
+ html: string;
+ };
+ url: string;
+ author: {
+ name: string;
+ url: string;
+ };
+ responsible?: {
+ name: string;
+ url: string;
+ };
+ assignee?: {
+ name: string;
+ url: string;
+ };
+ status: {
+ name: string;
+ color: string;
+ };
+ type: {
+ name: string;
+ color: string;
+ };
+ priority?: {
+ name: string;
+ color: string;
+ };
+ percentageDone?: number | null;
+ dueDate?: string | null;
+ };
+ "org.matrix.matrix-hookshot.openproject.project"?: {
+ id: number;
+ name: string;
+ url: string;
+ };
+ "org.matrix.matrix-hookshot.commands": {
+ "org.matrix.matrix-hookshot.openproject.command.close": {
+ label: "Close work package";
+ };
+ };
+ "org.matrix.matrix-hookshot.openproject.work_package.changed"?: {
+ subject?: string;
+ description?: {
+ plain: string;
+ html?: string;
+ };
+ assignee?: number;
+ status?: {
+ name: string;
+ color: string;
+ };
+ type?: number;
+ responsible?: number;
+ priority?: {
+ name: string;
+ color: string;
+ };
+ percentageDone?: number | null;
+ dueDate?: string | null;
+ };
+}
+
+const Root = styled.div`
+ margin-top: var(--cpd-space-1x);
+ margin-bottom: var(--cpd-space-1x);
+ gap: var(--cpd-space-2x);
+ display: flex;
+ flex-direction: column;
+`;
+
+const WidgetWorkPackageStatus = styled.div`
+ border-radius: 1em;
+ width: 0.9em;
+ height: 0.9em;
+ display: inline-block;
+ margin-right: var(--cpd-space-1x);
+`;
+
+const WidgetRow = styled.div`
+ gap: var(--cpd-space-4x);
+ display: flex;
+ flex-direction: row;
+ color: var(--cpd-color-text-secondary);
+ margin-top: auto;
+ margin-bottom: auto;
+`;
+
+const WidgetWorkPackageTitle = styled.a`
+ font-weight: var(--cpd-font-weight-semibold);
+`;
+
+const WidgetDescription = styled.div`
+ > p {
+ margin: 0;
+ }
+`;
+
+const WidgetBorder = styled.div`
+ width: var(--cpd-space-0-5x);
+ height: auto;
+ border-radius: var(--cpd-space-1x);
+`;
+const WidgetWrapper = styled.div`
+ display: flex;
+ flex-direction: row;
+ gap: var(--cpd-space-3x);
+ margin-top: var(--cpd-space-2x);
+ margin-bottom: var(--cpd-space-2x);
+`;
+
+const WidgetChangedText = styled.div`
+ color: var(--cpd-color-text-secondary);
+`;
+
+export function OpenProjectEventWidgetChanged({
+ data,
+}: {
+ data: OpenProjectContent;
+}) {
+ const {
+ ["org.matrix.matrix-hookshot.openproject.work_package"]: pkg,
+ "org.matrix.matrix-hookshot.openproject.work_package.changed": changes,
+ } = data;
+ if (!pkg || !changes) {
+ return null;
+ }
+ let innerContent = null;
+ if (changes.assignee !== undefined) {
+ innerContent = (
+
+ Assignee changed to {pkg.assignee?.name ?? "Nobody"}
+
+ );
+ } else if (changes.description !== undefined) {
+ innerContent = (
+
+ Description changed
+
+
+ );
+ } else if (changes.dueDate !== undefined) {
+ innerContent = (
+
+ Due date changed to {pkg.dueDate}
+
+ );
+ } else if (changes.percentageDone !== undefined) {
+ innerContent = (
+
+ Work package is now {pkg.percentageDone}% complete
+
+ );
+ } else if (changes.priority !== undefined) {
+ innerContent = (
+
+ Priority changed from {changes.priority.name} to{" "}
+ {pkg.priority?.name}
+
+ );
+ } else if (changes.responsible !== undefined) {
+ innerContent = (
+
+ Updated accountable person to{" "}
+ {pkg.responsible?.name ?? "Nobody"}
+
+ );
+ } else if (changes.status !== undefined) {
+ innerContent = (
+
+ Status changed from{" "}
+
+
+ {changes.status.name}
+ {" "}
+ to{" "}
+
+
+ {pkg.status.name}
+
+
+ );
+ } else if (changes.subject !== undefined) {
+ innerContent = Subject changed;
+ } else if (changes.type !== undefined) {
+ innerContent = (
+
+ Type changed to {pkg.type.name}
+
+ );
+ }
+
+ return (
+
+
+ Work package {pkg.id} updated
+
+
+
+
+
+ #{pkg.id} {pkg.subject}
+
+ {innerContent}
+
+
+
+ );
+}
+
+export function OpenProjectEventWidget({ data }: { data: OpenProjectContent }) {
+ const { ["org.matrix.matrix-hookshot.openproject.work_package"]: pkg } = data;
+ const description = pkg?.description?.html ?? pkg?.description.plain ?? "";
+ if (!pkg) {
+ return null;
+ }
+
+ return (
+
+
+ Work package {pkg.id} created by {pkg.author.name}
+
+
+
+
+
+ #{pkg.id} {pkg.subject}
+
+ {description && (
+
+ )}
+
+
+
+ {pkg.status.name}
+
+ {pkg.type.name}
+ {pkg.assignee?.name ? (
+ Assigned to {pkg.assignee.name}
+ ) : null}
+
+ Created by {pkg.author.name}
+
+
+
+
+
+
+ );
+}
diff --git a/web/elementModule/index.tsx b/web/elementModule/index.tsx
new file mode 100644
index 000000000..7ab486976
--- /dev/null
+++ b/web/elementModule/index.tsx
@@ -0,0 +1,90 @@
+/*
+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 {
+ Module,
+ Api,
+ ModuleFactory,
+ CustomMessageComponentProps,
+} from "@element-hq/element-web-module-api";
+import {
+ type OpenProjectContent,
+ OpenProjectEventWidget,
+ OpenProjectEventWidgetChanged,
+} from "./OpenProject";
+
+class HookshotModule implements Module {
+ public static readonly moduleApiVersion = "^1.0.0";
+
+ public constructor(private api: Api) {}
+
+ public async load(): Promise {
+ function shouldRender(
+ mxEvent: CustomMessageComponentProps["mxEvent"],
+ ): boolean {
+ if (mxEvent.getType() !== "m.room.message") {
+ return false;
+ }
+ const content = mxEvent.getContent();
+ const workPackageData =
+ content["org.matrix.matrix-hookshot.openproject.work_package"];
+ return !!workPackageData;
+ }
+
+ this.api.customComponents.registerMessageRenderer(
+ shouldRender,
+ (props) => {
+ const content = props.mxEvent.getContent();
+ if (
+ content["org.matrix.matrix-hookshot.openproject.work_package.changed"]
+ ) {
+ return (
+
+ );
+ }
+ return ;
+ },
+ { allowEditingEvent: false },
+ );
+ // NOT IMPLEMENTED YET
+ // this.api.menuApi.registerMessageMenuItems(
+ // MessageMenuTarget.TimelineTileContextMenu,
+ // (mxEvent) => {
+ // const client = this.api.matrixClient;
+ // const content = mxEvent.getContent() as OpenProjectContent;
+ // const workPackageData = content["org.matrix.matrix-hookshot.openproject.work_package"];
+ // if (!workPackageData) {
+ // return [];
+ // }
+ // return Object.entries(content["org.matrix.matrix-hookshot.commands"] ?? {}).map