diff --git a/jest.config.ts b/jest.config.ts
index 82f169b9de..e6c29b792d 100644
--- a/jest.config.ts
+++ b/jest.config.ts
@@ -46,6 +46,7 @@ const config: Config = {
],
collectCoverageFrom: [
"/src/**/*.{js,ts,tsx}",
+ "/packages/**/*.{js,ts,tsx}",
// getSessionLock is piped into a different JS context via stringification, and the coverage functionality is
// not available in that contest. So, turn off coverage instrumentation for it.
"!/src/utils/SessionLock.ts",
diff --git a/package.json b/package.json
index 4b5eb021f0..4b5456f4ec 100644
--- a/package.json
+++ b/package.json
@@ -68,10 +68,10 @@
"postinstall": "patch-package"
},
"resolutions": {
- "**/pretty-format/react-is": "19.1.1",
+ "**/pretty-format/react-is": "19.2.0",
"@playwright/test": "1.56.0",
- "@types/react": "19.1.14",
- "@types/react-dom": "19.1.9",
+ "@types/react": "19.2.2",
+ "@types/react-dom": "19.2.1",
"oidc-client-ts": "3.3.0",
"jwt-decode": "4.0.0",
"caniuse-lite": "1.0.30001750",
@@ -214,9 +214,9 @@
"@types/node-fetch": "^2.6.2",
"@types/pako": "^2.0.0",
"@types/qrcode": "^1.3.5",
- "@types/react": "19.1.14",
+ "@types/react": "19.2.2",
"@types/react-beautiful-dnd": "^13.0.0",
- "@types/react-dom": "19.1.9",
+ "@types/react-dom": "19.2.1",
"@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "2.16.0",
"@types/sdp-transform": "^2.4.10",
@@ -242,12 +242,12 @@
"eslint-config-prettier": "^10.0.0",
"eslint-plugin-deprecate": "0.8.7",
"eslint-plugin-import": "^2.25.4",
- "eslint-plugin-jest": "^28.0.0",
+ "eslint-plugin-jest": "^29.0.0",
"eslint-plugin-jsx-a11y": "^6.5.1",
"eslint-plugin-matrix-org": "^3.0.0",
"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-react-hooks": "^7.0.0",
"eslint-plugin-unicorn": "^56.0.0",
"express": "^5.0.0",
"fake-indexeddb": "^6.0.0",
diff --git a/packages/shared-components/package.json b/packages/shared-components/package.json
index df770b1491..4fabf2d83c 100644
--- a/packages/shared-components/package.json
+++ b/packages/shared-components/package.json
@@ -43,7 +43,8 @@
},
"dependencies": {
"matrix-web-i18n": "^3.4.0",
- "patch-package": "^8.0.1"
+ "patch-package": "^8.0.1",
+ "counterpart": "^0.18.6"
},
"devDependencies": {
"@storybook/addon-a11y": "^9.1.10",
@@ -54,7 +55,6 @@
"@storybook/test-runner": "^0.23.0",
"concurrently": "^9.2.1",
"eslint": "8",
- "jest-image-snapshot": "^6.5.1",
"eslint-plugin-storybook": "^9.1.10",
"jest-image-snapshot": "^6.5.1",
"patch-package": "^8.0.1",
diff --git a/packages/shared-components/src/ViewWrapper.tsx b/packages/shared-components/src/ViewWrapper.tsx
index 57b81bd5b9..d0d445ea5c 100644
--- a/packages/shared-components/src/ViewWrapper.tsx
+++ b/packages/shared-components/src/ViewWrapper.tsx
@@ -8,8 +8,8 @@
import React, { type JSX, useMemo, type ComponentType } from "react";
import { omitBy, pickBy } from "lodash";
-import { MockViewModel } from "./MockViewModel";
-import { type ViewModel } from "./ViewModel";
+import { MockViewModel } from "./viewmodel/MockViewModel";
+import { type ViewModel } from "./viewmodel/ViewModel";
interface ViewWrapperProps {
/**
diff --git a/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.test.tsx b/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.test.tsx
index 3f08eec28c..018b388f6b 100644
--- a/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.test.tsx
+++ b/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.test.tsx
@@ -13,7 +13,7 @@ import { fireEvent } from "@testing-library/dom";
import * as stories from "./AudioPlayerView.stories.tsx";
import { AudioPlayerView, type AudioPlayerViewActions, type AudioPlayerViewSnapshot } from "./AudioPlayerView";
-import { MockViewModel } from "../../MockViewModel";
+import { MockViewModel } from "../../viewmodel/MockViewModel.ts";
const { Default, NoMediaName, NoSize, HasError } = composeStories(stories);
diff --git a/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.tsx b/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.tsx
index f963c8b2cf..29fb02ba34 100644
--- a/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.tsx
+++ b/packages/shared-components/src/audio/AudioPlayerView/AudioPlayerView.tsx
@@ -7,7 +7,7 @@
import React, { type ChangeEventHandler, type JSX, type KeyboardEventHandler, type MouseEventHandler } from "react";
-import { type ViewModel } from "../../ViewModel";
+import { type ViewModel } from "../../viewmodel/ViewModel";
import { useViewModel } from "../../useViewModel";
import { MediaBody } from "../../message-body/MediaBody";
import { Flex } from "../../utils/Flex";
diff --git a/packages/shared-components/src/audio/AudioPlayerView/__snapshots__/AudioPlayerView.test.tsx.snap b/packages/shared-components/src/audio/AudioPlayerView/__snapshots__/AudioPlayerView.test.tsx.snap
index 790ab16870..0687ea44a8 100644
--- a/packages/shared-components/src/audio/AudioPlayerView/__snapshots__/AudioPlayerView.test.tsx.snap
+++ b/packages/shared-components/src/audio/AudioPlayerView/__snapshots__/AudioPlayerView.test.tsx.snap
@@ -15,7 +15,7 @@ exports[`AudioPlayerView renders the audio player in default state 1`] = `
Pill
Pill
diff --git a/packages/shared-components/src/rich-list/RichList/__snapshots__/RichList.test.tsx.snap b/packages/shared-components/src/rich-list/RichList/__snapshots__/RichList.test.tsx.snap
index 818984a786..5569c63a9c 100644
--- a/packages/shared-components/src/rich-list/RichList/__snapshots__/RichList.test.tsx.snap
+++ b/packages/shared-components/src/rich-list/RichList/__snapshots__/RichList.test.tsx.snap
@@ -11,12 +11,12 @@ exports[`RichItem renders the list 1`] = `
>
Rich List Title
Rich List Title
diff --git a/packages/shared-components/src/useViewModel.ts b/packages/shared-components/src/useViewModel.ts
index ef7b8ec0da..20c7070bff 100644
--- a/packages/shared-components/src/useViewModel.ts
+++ b/packages/shared-components/src/useViewModel.ts
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
import { useSyncExternalStore } from "react";
-import { type ViewModel } from "./ViewModel";
+import { type ViewModel } from "./viewmodel/ViewModel";
/**
* A small wrapper around useSyncExternalStore to use a view model in a shared component view
diff --git a/src/viewmodels/base/BaseViewModel.ts b/packages/shared-components/src/viewmodel/BaseViewModel.ts
similarity index 94%
rename from src/viewmodels/base/BaseViewModel.ts
rename to packages/shared-components/src/viewmodel/BaseViewModel.ts
index 1bd58b9196..ffb961d8e0 100644
--- a/src/viewmodels/base/BaseViewModel.ts
+++ b/packages/shared-components/src/viewmodel/BaseViewModel.ts
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
-import { type ViewModel } from "../../../packages/shared-components/src/ViewModel";
+import { type ViewModel } from "./ViewModel";
import { Disposables } from "./Disposables";
import { Snapshot } from "./Snapshot";
import { ViewModelSubscriptions } from "./ViewModelSubscriptions";
diff --git a/src/viewmodels/base/Disposables.ts b/packages/shared-components/src/viewmodel/Disposables.ts
similarity index 100%
rename from src/viewmodels/base/Disposables.ts
rename to packages/shared-components/src/viewmodel/Disposables.ts
diff --git a/packages/shared-components/src/MockViewModel.ts b/packages/shared-components/src/viewmodel/MockViewModel.ts
similarity index 100%
rename from packages/shared-components/src/MockViewModel.ts
rename to packages/shared-components/src/viewmodel/MockViewModel.ts
diff --git a/src/viewmodels/base/Snapshot.ts b/packages/shared-components/src/viewmodel/Snapshot.ts
similarity index 100%
rename from src/viewmodels/base/Snapshot.ts
rename to packages/shared-components/src/viewmodel/Snapshot.ts
diff --git a/packages/shared-components/src/ViewModel.ts b/packages/shared-components/src/viewmodel/ViewModel.ts
similarity index 100%
rename from packages/shared-components/src/ViewModel.ts
rename to packages/shared-components/src/viewmodel/ViewModel.ts
diff --git a/src/viewmodels/base/ViewModelSubscriptions.ts b/packages/shared-components/src/viewmodel/ViewModelSubscriptions.ts
similarity index 100%
rename from src/viewmodels/base/ViewModelSubscriptions.ts
rename to packages/shared-components/src/viewmodel/ViewModelSubscriptions.ts
diff --git a/packages/shared-components/src/viewmodel/index.ts b/packages/shared-components/src/viewmodel/index.ts
new file mode 100644
index 0000000000..3699f8dc3f
--- /dev/null
+++ b/packages/shared-components/src/viewmodel/index.ts
@@ -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.
+ */
+
+export * from "./BaseViewModel";
+export * from "./Disposables";
+export * from "./Snapshot";
+export * from "./ViewModelSubscriptions";
+export type * from "./ViewModel";
+export * from "./MockViewModel";
diff --git a/test/viewmodels/base/Disposables-test.ts b/packages/shared-components/src/viewmodel/tests/Disposables.test.ts
similarity index 95%
rename from test/viewmodels/base/Disposables-test.ts
rename to packages/shared-components/src/viewmodel/tests/Disposables.test.ts
index 577374a644..5b71f1871d 100644
--- a/test/viewmodels/base/Disposables-test.ts
+++ b/packages/shared-components/src/viewmodel/tests/Disposables.test.ts
@@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details.
import { EventEmitter } from "events";
-import { Disposables } from "../../../src/viewmodels/base/Disposables";
+import { Disposables } from "..";
describe("Disposable", () => {
it("isDisposed is true after dispose() is called", () => {
diff --git a/test/viewmodels/base/Snapshot-test.ts b/packages/shared-components/src/viewmodel/tests/Snapshot.test.ts
similarity index 95%
rename from test/viewmodels/base/Snapshot-test.ts
rename to packages/shared-components/src/viewmodel/tests/Snapshot.test.ts
index 796caa65ab..82cacfc02e 100644
--- a/test/viewmodels/base/Snapshot-test.ts
+++ b/packages/shared-components/src/viewmodel/tests/Snapshot.test.ts
@@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
-import { Snapshot } from "../../../src/viewmodels/base/Snapshot";
+import { Snapshot } from "..";
interface TestSnapshot {
key1: string;
diff --git a/packages/shared-components/yarn.lock b/packages/shared-components/yarn.lock
index b86fbc9ab4..2d5deba73c 100644
--- a/packages/shared-components/yarn.lock
+++ b/packages/shared-components/yarn.lock
@@ -2333,6 +2333,17 @@ core-util-is@~1.0.0:
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85"
integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==
+counterpart@^0.18.6:
+ version "0.18.6"
+ resolved "https://registry.yarnpkg.com/counterpart/-/counterpart-0.18.6.tgz#cf6b60d8ef99a4b44b8bf6445fa99b4bd1b2f9dd"
+ integrity sha512-cAIDAYbC3x8S2DDbvFEJ4TzPtPYXma25/kfAkfmprNLlkPWeX4SdUp1c2xklfphqCU3HnDaivR4R3BrAYf5OMA==
+ dependencies:
+ date-names "^0.1.11"
+ except "^0.1.3"
+ extend "^3.0.0"
+ pluralizers "^0.1.7"
+ sprintf-js "^1.0.3"
+
create-ecdh@^4.0.4:
version "4.0.4"
resolved "https://registry.yarnpkg.com/create-ecdh/-/create-ecdh-4.0.4.tgz#d6e7f4bffa66736085a0762fd3a632684dabcc4e"
@@ -2422,6 +2433,11 @@ cwd@^0.10.0:
find-pkg "^0.1.2"
fs-exists-sync "^0.1.0"
+date-names@^0.1.11:
+ version "0.1.13"
+ resolved "https://registry.yarnpkg.com/date-names/-/date-names-0.1.13.tgz#c4358f6f77c8056e2f5ea68fdbb05f0bf1e53bd0"
+ integrity sha512-IxxoeD9tdx8pXVcmqaRlPvrXIsSrSrIZzfzlOkm9u+hyzKp5Wk/odt9O/gd7Ockzy8n/WHeEpTVJ2bF3mMV4LA==
+
de-indent@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/de-indent/-/de-indent-1.0.2.tgz#b2038e846dc33baa5796128d0804b455b8c1e21d"
@@ -2870,6 +2886,13 @@ evp_bytestokey@^1.0.0, evp_bytestokey@^1.0.3:
md5.js "^1.3.4"
safe-buffer "^5.1.1"
+except@^0.1.3:
+ version "0.1.3"
+ resolved "https://registry.yarnpkg.com/except/-/except-0.1.3.tgz#98261c91958551536b44482238e9783fb73d292a"
+ integrity sha512-ouwgJavvMOTOfy0RE8NGQFAIoWh8ehJhkuxDyXxngMVTxTq7HGE7gZopZhqKFnu5lZLI+qQdtvJ8n03ehp7RJg==
+ dependencies:
+ indexof "0.0.1"
+
execa@^5.0.0:
version "5.1.1"
resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd"
@@ -2918,6 +2941,11 @@ exsolve@^1.0.7:
resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.0.7.tgz#3b74e4c7ca5c5f9a19c3626ca857309fa99f9e9e"
integrity sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==
+extend@^3.0.0:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
+ integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
+
fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
version "3.1.3"
resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
@@ -3449,6 +3477,11 @@ indent-string@^4.0.0:
resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251"
integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==
+indexof@0.0.1:
+ version "0.0.1"
+ resolved "https://registry.yarnpkg.com/indexof/-/indexof-0.0.1.tgz#82dc336d232b9062179d05ab3293a66059fd435d"
+ integrity sha512-i0G7hLJ1z0DE8dsqJa2rycj9dBmNKgXBvotXtZYXakU9oivfB9Uj2ZBC27qqef2U58/ZLwalxa1X/RDCdkHtVg==
+
inflight@^1.0.4:
version "1.0.6"
resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
@@ -4994,6 +5027,11 @@ playwright@^1.14.0:
optionalDependencies:
fsevents "2.3.2"
+pluralizers@^0.1.7:
+ version "0.1.7"
+ resolved "https://registry.yarnpkg.com/pluralizers/-/pluralizers-0.1.7.tgz#8d38dd0a1b660e739b10ab2eab10b684c9d50142"
+ integrity sha512-mw6AejUiCaMQ6uPN9ObjJDTnR5AnBSmnHHy3uVTbxrSFSxO5scfwpTs8Dxyb6T2v7GSulhvOq+pm9y+hXUvtOA==
+
pngjs@^3.4.0:
version "3.4.0"
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f"
@@ -5535,6 +5573,11 @@ spawnd@^5.0.0:
tree-kill "^1.2.2"
wait-port "^0.2.9"
+sprintf-js@^1.0.3:
+ version "1.1.3"
+ resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a"
+ integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==
+
sprintf-js@~1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c"
diff --git a/sonar-project.properties b/sonar-project.properties
index e4ce1ea56f..205d82fe6c 100644
--- a/sonar-project.properties
+++ b/sonar-project.properties
@@ -5,7 +5,7 @@ sonar.organization=element-hq
#sonar.sourceEncoding=UTF-8
sonar.sources=src,res,packages/shared-components/src
-sonar.tests=test,playwright,src
+sonar.tests=test,playwright,src,packages
sonar.test.inclusions=test/*,playwright/*,src/**/*.test.*,packages/*/src/**/*.test.*
sonar.exclusions=__mocks__,docs,element.io,nginx
diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts
index c22a6bab33..bf39d9ea1f 100644
--- a/src/ContentMessages.ts
+++ b/src/ContentMessages.ts
@@ -53,7 +53,7 @@ import ErrorDialog from "./components/views/dialogs/ErrorDialog";
import UploadFailureDialog from "./components/views/dialogs/UploadFailureDialog";
import UploadConfirmDialog from "./components/views/dialogs/UploadConfirmDialog";
import { createThumbnail } from "./utils/image-media";
-import { attachMentions, attachRelation } from "./components/views/rooms/SendMessageComposer";
+import { attachMentions, attachRelation } from "./utils/messages.ts";
import { doMaybeLocalRoomAction } from "./utils/local-room";
import { SdkContextClass } from "./contexts/SDKContext";
import { blobIsAnimated } from "./utils/Image.ts";
diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx
index 92d60b7571..f6677286b8 100644
--- a/src/components/views/rooms/EditMessageComposer.tsx
+++ b/src/components/views/rooms/EditMessageComposer.tsx
@@ -43,7 +43,7 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { PosthogAnalytics } from "../../../PosthogAnalytics";
import { editorRoomKey, editorStateKey } from "../../../Editing";
import type DocumentOffset from "../../../editor/offset";
-import { attachMentions, attachRelation } from "./SendMessageComposer";
+import { attachMentions, attachRelation } from "../../../utils/messages";
import { filterBoolean } from "../../../utils/arrays";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx
index aae9cfd0d0..51b14168df 100644
--- a/src/components/views/rooms/SendMessageComposer.tsx
+++ b/src/components/views/rooms/SendMessageComposer.tsx
@@ -8,10 +8,8 @@ Please see LICENSE files in the repository root for full details.
import React, { createRef, type KeyboardEvent, type SyntheticEvent } from "react";
import {
- type IContent,
type MatrixEvent,
type IEventRelation,
- type IMentions,
type Room,
EventType,
MsgType,
@@ -35,7 +33,7 @@ import {
unescapeMessage,
} from "../../../editor/serialize";
import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer";
-import { CommandPartCreator, type Part, type PartCreator, type SerializedPart, Type } from "../../../editor/parts";
+import { CommandPartCreator, type Part, type PartCreator, type SerializedPart } from "../../../editor/parts";
import { findEditableEvent } from "../../../utils/EventUtils";
import SendHistoryManager from "../../../SendHistoryManager";
import { CommandCategories } from "../../../SlashCommands";
@@ -61,108 +59,11 @@ import { type Caret } from "../../../editor/caret";
import { type IDiff } from "../../../editor/diff";
import { getBlobSafeMimeType } from "../../../utils/blobs";
import { EMOJI_REGEX } from "../../../HtmlUtils";
+import { attachMentions, attachRelation } from "../../../utils/messages";
// The prefix used when persisting editor drafts to localstorage.
export const EDITOR_STATE_STORAGE_PREFIX = "mx_cider_state_";
-/**
- * Build the mentions information based on the editor model (and any related events):
- *
- * 1. Search the model parts for room or user pills and fill in the mentions object.
- * 2. If this is a reply to another event, include any user mentions from that
- * (but do not include a room mention).
- *
- * @param sender - The Matrix ID of the user sending the event.
- * @param content - The event content.
- * @param model - The editor model to search for mentions, null if there is no editor.
- * @param replyToEvent - The event being replied to or undefined if it is not a reply.
- * @param editedContent - The content of the parent event being edited.
- */
-export function attachMentions(
- sender: string,
- content: IContent,
- model: EditorModel | null,
- replyToEvent: MatrixEvent | undefined,
- editedContent: IContent | null = null,
-): void {
- // We always attach the mentions even if the home server doesn't yet support
- // intentional mentions. This is safe because m.mentions is an additive change
- // that should simply be ignored by incapable home servers.
-
- // The mentions property *always* gets included to disable legacy push rules.
- const mentions: IMentions = (content["m.mentions"] = {});
-
- const userMentions = new Set();
- let roomMention = false;
-
- // If there's a reply, initialize the mentioned users as the sender of that event.
- if (replyToEvent) {
- userMentions.add(replyToEvent.sender!.userId);
- }
-
- // If user provided content is available, check to see if any users are mentioned.
- if (model) {
- // Add any mentioned users in the current content.
- for (const part of model.parts) {
- if (part.type === Type.UserPill) {
- userMentions.add(part.resourceId);
- } else if (part.type === Type.AtRoomPill) {
- roomMention = true;
- }
- }
- }
-
- // Ensure the *current* user isn't listed in the mentioned users.
- userMentions.delete(sender);
-
- // Finally, if this event is editing a previous event, only include users who
- // were not previously mentioned and a room mention if the previous event was
- // not a room mention.
- if (editedContent) {
- // First, the new event content gets the *full* set of users.
- const newContent = content["m.new_content"];
- const newMentions: IMentions = (newContent["m.mentions"] = {});
-
- // Only include the users/room if there is any content.
- if (userMentions.size) {
- newMentions.user_ids = [...userMentions];
- }
- if (roomMention) {
- newMentions.room = true;
- }
-
- // Fetch the mentions from the original event and remove any previously
- // mentioned users.
- const prevMentions = editedContent["m.mentions"];
- if (Array.isArray(prevMentions?.user_ids)) {
- prevMentions!.user_ids.forEach((userId) => userMentions.delete(userId));
- }
-
- // If the original event mentioned the room, nothing to do here.
- if (prevMentions?.room) {
- roomMention = false;
- }
- }
-
- // Only include the users/room if there is any content.
- if (userMentions.size) {
- mentions.user_ids = [...userMentions];
- }
- if (roomMention) {
- mentions.room = true;
- }
-}
-
-// Merges favouring the given relation
-export function attachRelation(content: IContent, relation?: IEventRelation): void {
- if (relation) {
- content["m.relates_to"] = {
- ...(content["m.relates_to"] || {}),
- ...relation,
- };
- }
-}
-
// exported for tests
export function createMessageContent(
sender: string,
diff --git a/src/components/views/rooms/VoiceRecordComposerTile.tsx b/src/components/views/rooms/VoiceRecordComposerTile.tsx
index b329cd84ea..9c13f1c872 100644
--- a/src/components/views/rooms/VoiceRecordComposerTile.tsx
+++ b/src/components/views/rooms/VoiceRecordComposerTile.tsx
@@ -29,7 +29,7 @@ import InlineSpinner from "../elements/InlineSpinner";
import { PlaybackManager } from "../../../audio/PlaybackManager";
import { doMaybeLocalRoomAction } from "../../../utils/local-room";
import defaultDispatcher from "../../../dispatcher/dispatcher";
-import { attachMentions, attachRelation } from "./SendMessageComposer";
+import { attachMentions, attachRelation } from "../../../utils/messages";
import { addReplyToMessageContent } from "../../../utils/Reply";
import RoomContext from "../../../contexts/RoomContext";
import { type IUpload, type VoiceMessageRecording } from "../../../audio/VoiceMessageRecording";
diff --git a/src/components/views/rooms/wysiwyg_composer/utils/message.ts b/src/components/views/rooms/wysiwyg_composer/utils/message.ts
index b794ba6fe4..8b9791d4ff 100644
--- a/src/components/views/rooms/wysiwyg_composer/utils/message.ts
+++ b/src/components/views/rooms/wysiwyg_composer/utils/message.ts
@@ -33,7 +33,7 @@ import { CommandCategories, getCommand } from "../../../../../SlashCommands";
import { runSlashCommand, shouldSendAnyway } from "../../../../../editor/commands";
import { Action } from "../../../../../dispatcher/actions";
import { addReplyToMessageContent } from "../../../../../utils/Reply";
-import { attachRelation } from "../../SendMessageComposer";
+import { attachRelation } from "../../../../../utils/messages";
export interface SendMessageParams {
mxClient: MatrixClient;
diff --git a/src/utils/messages.ts b/src/utils/messages.ts
new file mode 100644
index 0000000000..b6040b536b
--- /dev/null
+++ b/src/utils/messages.ts
@@ -0,0 +1,109 @@
+/*
+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, type IContent, type IMentions, type IEventRelation } from "matrix-js-sdk/src/matrix";
+
+import type EditorModel from "../editor/model";
+import { Type } from "../editor/parts";
+
+/**
+ * Build the mentions information based on the editor model (and any related events):
+ *
+ * 1. Search the model parts for room or user pills and fill in the mentions object.
+ * 2. If this is a reply to another event, include any user mentions from that
+ * (but do not include a room mention).
+ *
+ * @param sender - The Matrix ID of the user sending the event.
+ * @param content - The event content.
+ * @param model - The editor model to search for mentions, null if there is no editor.
+ * @param replyToEvent - The event being replied to or undefined if it is not a reply.
+ * @param editedContent - The content of the parent event being edited.
+ */
+export function attachMentions(
+ sender: string,
+ content: IContent,
+ model: EditorModel | null,
+ replyToEvent: MatrixEvent | undefined,
+ editedContent: IContent | null = null,
+): void {
+ // We always attach the mentions even if the home server doesn't yet support
+ // intentional mentions. This is safe because m.mentions is an additive change
+ // that should simply be ignored by incapable home servers.
+
+ // The mentions property *always* gets included to disable legacy push rules.
+ const mentions: IMentions = (content["m.mentions"] = {});
+
+ const userMentions = new Set();
+ let roomMention = false;
+
+ // If there's a reply, initialize the mentioned users as the sender of that event.
+ if (replyToEvent) {
+ userMentions.add(replyToEvent.sender!.userId);
+ }
+
+ // If user provided content is available, check to see if any users are mentioned.
+ if (model) {
+ // Add any mentioned users in the current content.
+ for (const part of model.parts) {
+ if (part.type === Type.UserPill) {
+ userMentions.add(part.resourceId);
+ } else if (part.type === Type.AtRoomPill) {
+ roomMention = true;
+ }
+ }
+ }
+
+ // Ensure the *current* user isn't listed in the mentioned users.
+ userMentions.delete(sender);
+
+ // Finally, if this event is editing a previous event, only include users who
+ // were not previously mentioned and a room mention if the previous event was
+ // not a room mention.
+ if (editedContent) {
+ // First, the new event content gets the *full* set of users.
+ const newContent = content["m.new_content"];
+ const newMentions: IMentions = (newContent["m.mentions"] = {});
+
+ // Only include the users/room if there is any content.
+ if (userMentions.size) {
+ newMentions.user_ids = [...userMentions];
+ }
+ if (roomMention) {
+ newMentions.room = true;
+ }
+
+ // Fetch the mentions from the original event and remove any previously
+ // mentioned users.
+ const prevMentions = editedContent["m.mentions"];
+ if (Array.isArray(prevMentions?.user_ids)) {
+ prevMentions!.user_ids.forEach((userId) => userMentions.delete(userId));
+ }
+
+ // If the original event mentioned the room, nothing to do here.
+ if (prevMentions?.room) {
+ roomMention = false;
+ }
+ }
+
+ // Only include the users/room if there is any content.
+ if (userMentions.size) {
+ mentions.user_ids = [...userMentions];
+ }
+ if (roomMention) {
+ mentions.room = true;
+ }
+}
+
+// Merges favouring the given relation
+export function attachRelation(content: IContent, relation?: IEventRelation): void {
+ if (relation) {
+ content["m.relates_to"] = {
+ ...(content["m.relates_to"] || {}),
+ ...relation,
+ };
+ }
+}
diff --git a/src/viewmodels/audio/AudioPlayerViewModel.ts b/src/viewmodels/audio/AudioPlayerViewModel.ts
index 025265b250..9c8d4c4822 100644
--- a/src/viewmodels/audio/AudioPlayerViewModel.ts
+++ b/src/viewmodels/audio/AudioPlayerViewModel.ts
@@ -17,7 +17,7 @@ import { UPDATE_EVENT } from "../../stores/AsyncStore";
import { percentageOf } from "../../../packages/shared-components/src/utils/numbers";
import { getKeyBindingsManager } from "../../KeyBindingsManager";
import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts";
-import { BaseViewModel } from "../base/BaseViewModel";
+import { BaseViewModel } from "../../../packages/shared-components/src/viewmodel";
/**
* The number of seconds to skip when the user presses the left or right arrow keys.
diff --git a/src/viewmodels/event-tiles/TextualEventViewModel.ts b/src/viewmodels/event-tiles/TextualEventViewModel.ts
index e887da82b0..a561dfa90d 100644
--- a/src/viewmodels/event-tiles/TextualEventViewModel.ts
+++ b/src/viewmodels/event-tiles/TextualEventViewModel.ts
@@ -11,7 +11,7 @@ import { type EventTileTypeProps } from "../../events/EventTileFactory";
import { MatrixClientPeg } from "../../MatrixClientPeg";
import { textForEvent } from "../../TextForEvent";
import { type TextualEventViewSnapshot } from "../../../packages/shared-components/src/event-tiles/TextualEventView/TextualEventView";
-import { BaseViewModel } from "../base/BaseViewModel";
+import { BaseViewModel } from "../../../packages/shared-components/src/viewmodel";
export class TextualEventViewModel extends BaseViewModel {
public constructor(props: EventTileTypeProps) {
diff --git a/test/unit-tests/components/structures/__snapshots__/FilePanel-test.tsx.snap b/test/unit-tests/components/structures/__snapshots__/FilePanel-test.tsx.snap
index 069b4a704a..1307a47d06 100644
--- a/test/unit-tests/components/structures/__snapshots__/FilePanel-test.tsx.snap
+++ b/test/unit-tests/components/structures/__snapshots__/FilePanel-test.tsx.snap
@@ -19,7 +19,7 @@ exports[`FilePanel renders empty state 1`] = `