diff --git a/src/ContentMessages.ts b/src/ContentMessages.ts index f022f5e4f8..00ff644a39 100644 --- a/src/ContentMessages.ts +++ b/src/ContentMessages.ts @@ -48,7 +48,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 { attachRelation } from "./components/views/rooms/SendMessageComposer"; +import { attachMentions, attachRelation } from "./components/views/rooms/SendMessageComposer"; import { doMaybeLocalRoomAction } from "./utils/local-room"; import { SdkContextClass } from "./contexts/SDKContext"; @@ -492,6 +492,8 @@ export default class ContentMessages { msgtype: MsgType.File, // set more specifically later }; + // Attach mentions, which really only applies if there's a replyToEvent. + attachMentions(matrixClient.getSafeUserId(), content, null, replyToEvent); attachRelation(content, relation); if (replyToEvent) { addReplyToMessageContent(content, replyToEvent, { diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 51d493030e..e6eb94924d 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -235,6 +235,8 @@ class MatrixClientPegClass implements IMatrixClientPeg { SlidingSyncManager.instance.startSpidering(100, 50); // 100 rooms at a time, 50ms apart } + opts.intentionalMentions = SettingsStore.getValue("feature_intentional_mentions"); + // Connect the matrix client to the dispatcher and setting handlers MatrixActionCreators.start(this.matrixClient); MatrixClientBackedSettingsHandler.matrixClient = this.matrixClient; diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index 3698a7fb11..368a29b015 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -48,6 +48,7 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { PosthogAnalytics } from "../../../PosthogAnalytics"; import { editorRoomKey, editorStateKey } from "../../../Editing"; import DocumentOffset from "../../../editor/offset"; +import { attachMentions, attachRelation } from "./SendMessageComposer"; function getHtmlReplyFallback(mxEvent: MatrixEvent): string { const html = mxEvent.getContent().formatted_body; @@ -90,8 +91,9 @@ export function createEditContent(model: EditorModel, editedEvent: MatrixEvent): body: body, }; const contentBody: IContent = { - msgtype: newContent.msgtype, - body: `${plainPrefix} * ${body}`, + "msgtype": newContent.msgtype, + "body": `${plainPrefix} * ${body}`, + "m.new_content": newContent, }; const formattedBody = htmlSerializeIfNeeded(model, { @@ -105,16 +107,15 @@ export function createEditContent(model: EditorModel, editedEvent: MatrixEvent): contentBody.formatted_body = `${htmlPrefix} * ${formattedBody}`; } - return Object.assign( - { - "m.new_content": newContent, - "m.relates_to": { - rel_type: "m.replace", - event_id: editedEvent.getId(), - }, - }, - contentBody, - ); + // Build the mentions properties for both the content and new_content. + // + // TODO If this is a reply we need to include all the users from it. + if (SettingsStore.getValue("feature_intentional_mentions")) { + attachMentions(editedEvent.sender!.userId, contentBody, model, undefined, editedEvent.getContent()); + } + attachRelation(contentBody, { rel_type: "m.replace", event_id: editedEvent.getId() }); + + return contentBody; } interface IEditMessageComposerProps extends MatrixClientProps { diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index d10db80df4..745b2c3ab6 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -16,7 +16,7 @@ limitations under the License. import React, { ClipboardEvent, createRef, KeyboardEvent } from "react"; import EMOJI_REGEX from "emojibase-regex"; -import { IContent, MatrixEvent, IEventRelation } from "matrix-js-sdk/src/models/event"; +import { IContent, MatrixEvent, IEventRelation, IMentions } from "matrix-js-sdk/src/models/event"; import { DebouncedFunc, throttle } from "lodash"; import { EventType, RelationType } from "matrix-js-sdk/src/@types/event"; import { logger } from "matrix-js-sdk/src/logger"; @@ -36,7 +36,7 @@ import { unescapeMessage, } from "../../../editor/serialize"; import BasicMessageComposer, { REGEX_EMOTICON } from "./BasicMessageComposer"; -import { CommandPartCreator, Part, PartCreator, SerializedPart } from "../../../editor/parts"; +import { CommandPartCreator, Part, PartCreator, SerializedPart, Type } from "../../../editor/parts"; import { findEditableEvent } from "../../../utils/EventUtils"; import SendHistoryManager from "../../../SendHistoryManager"; import { CommandCategories } from "../../../SlashCommands"; @@ -60,6 +60,102 @@ import { PosthogAnalytics } from "../../../PosthogAnalytics"; import { addReplyToMessageContent } from "../../../utils/Reply"; import { doMaybeLocalRoomAction } from "../../../utils/local-room"; +/** + * 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 { + // If this feature is disabled, do nothing. + if (!SettingsStore.getValue("feature_intentional_mentions")) { + return; + } + + // The mentions property *always* gets included to disable legacy push rules. + const mentions: IMentions = (content["org.matrix.msc3952.mentions"] = {}); + + const userMentions = new Set(); + let roomMention = false; + + // If there's a reply, initialize the mentioned users as the sender of that + // event + any mentioned users in that event. + if (replyToEvent) { + userMentions.add(replyToEvent.sender!.userId); + // TODO What do we do if the reply event *doeesn't* have this property? + // Try to fish out replies from the contents? + const userIds = replyToEvent.getContent()["org.matrix.msc3952.mentions"]?.user_ids; + if (Array.isArray(userIds)) { + userIds.forEach((userId) => userMentions.add(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["org.matrix.msc3952.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["org.matrix.msc3952.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) { @@ -72,6 +168,7 @@ export function attachRelation(content: IContent, relation?: IEventRelation): vo // exported for tests export function createMessageContent( + sender: string, model: EditorModel, replyToEvent: MatrixEvent | undefined, relation: IEventRelation | undefined, @@ -102,6 +199,9 @@ export function createMessageContent( content.formatted_body = formattedBody; } + // Build the mentions property and add it to the event content. + attachMentions(sender, content, model, replyToEvent); + attachRelation(content, relation); if (replyToEvent) { addReplyToMessageContent(content, replyToEvent, { @@ -381,6 +481,8 @@ export class SendMessageComposer extends React.Component Math.round(v * 1024)), ); + // Attach mentions, which really only applies if there's a replyToEvent. + attachMentions(MatrixClientPeg.get().getSafeUserId(), content, null, replyToEvent); attachRelation(content, relation); if (replyToEvent) { addReplyToMessageContent(content, replyToEvent, { diff --git a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts index 0819b758d8..6f13d3fd27 100644 --- a/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts +++ b/src/components/views/rooms/wysiwyg_composer/utils/createMessageContent.ts @@ -125,6 +125,8 @@ export async function createMessageContent( const newRelation = isEditing ? { ...relation, rel_type: "m.replace", event_id: editedEvent.getId() } : relation; + // TODO Do we need to attach mentions here? + // TODO Handle editing? attachRelation(content, newRelation); if (!isEditing && replyToEvent && permalinkCreator) { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 862f33eec3..aca7548a22 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -987,6 +987,7 @@ "Show polls button": "Show polls button", "Insert a trailing colon after user mentions at the start of a message": "Insert a trailing colon after user mentions at the start of a message", "Hide notification dot (only display counters badges)": "Hide notification dot (only display counters badges)", + "Enable intentional mentions": "Enable intentional mentions", "Use a more compact 'Modern' layout": "Use a more compact 'Modern' layout", "Show a placeholder for removed messages": "Show a placeholder for removed messages", "Show join/leave messages (invites/removes/bans unaffected)": "Show join/leave messages (invites/removes/bans unaffected)", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 1d07e4f044..a4271653d7 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -540,6 +540,17 @@ export const SETTINGS: { [setting: string]: ISetting } = { labsGroup: LabGroup.Rooms, default: false, }, + // MSC3952 intentional mentions support. + "feature_intentional_mentions": { + isFeature: true, + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, + displayName: _td("Enable intentional mentions"), + labsGroup: LabGroup.Rooms, + default: false, + controller: new ServerSupportUnstableFeatureController("feature_intentional_mentions", defaultWatchManager, [ + ["org.matrix.msc3952_intentional_mentions"], + ]), + }, "useCompactLayout": { supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, displayName: _td("Use a more compact 'Modern' layout"), diff --git a/test/ContentMessages-test.ts b/test/ContentMessages-test.ts index f605b6c4e2..e725d4d9ce 100644 --- a/test/ContentMessages-test.ts +++ b/test/ContentMessages-test.ts @@ -21,8 +21,9 @@ import encrypt, { IEncryptedFile } from "matrix-encrypt-attachment"; import ContentMessages, { UploadCanceledError, uploadFile } from "../src/ContentMessages"; import { doMaybeLocalRoomAction } from "../src/utils/local-room"; -import { createTestClient } from "./test-utils"; +import { createTestClient, mkEvent } from "./test-utils"; import { BlurhashEncoder } from "../src/BlurhashEncoder"; +import SettingsStore from "../src/settings/SettingsStore"; jest.mock("matrix-encrypt-attachment", () => ({ encryptAttachment: jest.fn().mockResolvedValue({}) })); @@ -51,6 +52,7 @@ describe("ContentMessages", () => { beforeEach(() => { client = { + getSafeUserId: jest.fn().mockReturnValue("@alice:test"), sendStickerMessage: jest.fn(), sendMessage: jest.fn(), isRoomEncrypted: jest.fn().mockReturnValue(false), @@ -221,6 +223,34 @@ describe("ContentMessages", () => { expect(upload.total).toBe(1234); await prom; }); + + it("properly handles replies", async () => { + jest.spyOn(SettingsStore, "getValue").mockImplementation( + (settingName) => settingName === "feature_intentional_mentions", + ); + + mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" }); + const file = new File([], "fileName", { type: "image/jpeg" }); + const replyToEvent = mkEvent({ + type: "m.room.message", + user: "@bob:test", + room: roomId, + content: {}, + event: true, + }); + await contentMessages.sendContentToRoom(file, roomId, undefined, client, replyToEvent); + expect(client.sendMessage).toHaveBeenCalledWith( + roomId, + null, + expect.objectContaining({ + "url": "mxc://server/file", + "msgtype": "m.image", + "org.matrix.msc3952.mentions": { + user_ids: ["@bob:test"], + }, + }), + ); + }); }); describe("getCurrentUploads", () => { diff --git a/test/components/views/rooms/SendMessageComposer-test.tsx b/test/components/views/rooms/SendMessageComposer-test.tsx index 70c7a788e2..5d840eccb0 100644 --- a/test/components/views/rooms/SendMessageComposer-test.tsx +++ b/test/components/views/rooms/SendMessageComposer-test.tsx @@ -16,10 +16,11 @@ limitations under the License. import React from "react"; import { fireEvent, render, waitFor } from "@testing-library/react"; -import { MatrixClient, MsgType } from "matrix-js-sdk/src/matrix"; +import { IContent, MatrixClient, MsgType } from "matrix-js-sdk/src/matrix"; import { mocked } from "jest-mock"; import SendMessageComposer, { + attachMentions, createMessageContent, isQuickReaction, } from "../../../../src/components/views/rooms/SendMessageComposer"; @@ -38,6 +39,7 @@ import { mockPlatformPeg } from "../../../test-utils/platform"; import { doMaybeLocalRoomAction } from "../../../../src/utils/local-room"; import { addTextToComposer } from "../../../test-utils/composer"; import dis from "../../../../src/dispatcher/dispatcher"; +import SettingsStore from "../../../../src/settings/SettingsStore"; jest.mock("../../../../src/utils/local-room", () => ({ doMaybeLocalRoomAction: jest.fn(), @@ -89,7 +91,7 @@ describe("", () => { const documentOffset = new DocumentOffset(11, true); model.update("hello world", "insertText", documentOffset); - const content = createMessageContent(model, undefined, undefined, permalinkCreator); + const content = createMessageContent("@alice:test", model, undefined, undefined, permalinkCreator); expect(content).toEqual({ body: "hello world", @@ -102,7 +104,7 @@ describe("", () => { const documentOffset = new DocumentOffset(13, true); model.update("hello *world*", "insertText", documentOffset); - const content = createMessageContent(model, undefined, undefined, permalinkCreator); + const content = createMessageContent("@alice:test", model, undefined, undefined, permalinkCreator); expect(content).toEqual({ body: "hello *world*", @@ -117,7 +119,7 @@ describe("", () => { const documentOffset = new DocumentOffset(22, true); model.update("/me blinks __quickly__", "insertText", documentOffset); - const content = createMessageContent(model, undefined, undefined, permalinkCreator); + const content = createMessageContent("@alice:test", model, undefined, undefined, permalinkCreator); expect(content).toEqual({ body: "blinks __quickly__", @@ -133,7 +135,7 @@ describe("", () => { model.update("/me ✨sparkles✨", "insertText", documentOffset); expect(model.parts.length).toEqual(4); // Emoji count as non-text - const content = createMessageContent(model, undefined, undefined, permalinkCreator); + const content = createMessageContent("@alice:test", model, undefined, undefined, permalinkCreator); expect(content).toEqual({ body: "✨sparkles✨", @@ -147,7 +149,7 @@ describe("", () => { model.update("//dev/null is my favourite place", "insertText", documentOffset); - const content = createMessageContent(model, undefined, undefined, permalinkCreator); + const content = createMessageContent("@alice:test", model, undefined, undefined, permalinkCreator); expect(content).toEqual({ body: "/dev/null is my favourite place", @@ -156,6 +158,196 @@ describe("", () => { }); }); + describe("attachMentions", () => { + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockImplementation( + (settingName) => settingName === "feature_intentional_mentions", + ); + }); + + afterEach(() => { + jest.spyOn(SettingsStore, "getValue").mockReset(); + }); + + const partsCreator = createPartCreator(); + + it("no mentions", () => { + const model = new EditorModel([], partsCreator); + const content: IContent = {}; + attachMentions("@alice:test", content, model, undefined); + expect(content).toEqual({ + "org.matrix.msc3952.mentions": {}, + }); + }); + + it("test user mentions", () => { + const model = new EditorModel([partsCreator.userPill("Bob", "@bob:test")], partsCreator); + const content: IContent = {}; + attachMentions("@alice:test", content, model, undefined); + expect(content).toEqual({ + "org.matrix.msc3952.mentions": { user_ids: ["@bob:test"] }, + }); + }); + + it("test reply", () => { + // Replying to an event adds the sender to the list of mentioned users. + const model = new EditorModel([], partsCreator); + let replyToEvent = mkEvent({ + type: "m.room.message", + user: "@bob:test", + room: "!abc:test", + content: { "org.matrix.msc3952.mentions": {} }, + event: true, + }); + let content: IContent = {}; + attachMentions("@alice:test", content, model, replyToEvent); + expect(content).toEqual({ + "org.matrix.msc3952.mentions": { user_ids: ["@bob:test"] }, + }); + + // It also adds any other mentioned users, but removes yourself. + replyToEvent = mkEvent({ + type: "m.room.message", + user: "@bob:test", + room: "!abc:test", + content: { "org.matrix.msc3952.mentions": { user_ids: ["@alice:test", "@charlie:test"] } }, + event: true, + }); + content = {}; + attachMentions("@alice:test", content, model, replyToEvent); + expect(content).toEqual({ + "org.matrix.msc3952.mentions": { user_ids: ["@bob:test", "@charlie:test"] }, + }); + }); + + it("test room mention", () => { + const model = new EditorModel([partsCreator.atRoomPill("@room")], partsCreator); + const content: IContent = {}; + attachMentions("@alice:test", content, model, undefined); + expect(content).toEqual({ + "org.matrix.msc3952.mentions": { room: true }, + }); + }); + + it("test reply to room mention", () => { + // Replying to a room mention shouldn't automatically be a room mention. + const model = new EditorModel([], partsCreator); + const replyToEvent = mkEvent({ + type: "m.room.message", + user: "@alice:test", + room: "!abc:test", + content: { "org.matrix.msc3952.mentions": { room: true } }, + event: true, + }); + const content: IContent = {}; + attachMentions("@alice:test", content, model, replyToEvent); + expect(content).toEqual({ + "org.matrix.msc3952.mentions": {}, + }); + }); + + it("test broken mentions", () => { + // Replying to a room mention shouldn't automatically be a room mention. + const model = new EditorModel([], partsCreator); + const replyToEvent = mkEvent({ + type: "m.room.message", + user: "@alice:test", + room: "!abc:test", + // @ts-ignore - Purposefully testing invalid data. + content: { "org.matrix.msc3952.mentions": { user_ids: "@bob:test" } }, + event: true, + }); + const content: IContent = {}; + attachMentions("@alice:test", content, model, replyToEvent); + expect(content).toEqual({ + "org.matrix.msc3952.mentions": {}, + }); + }); + + describe("attachMentions with edit", () => { + it("no mentions", () => { + const model = new EditorModel([], partsCreator); + const content: IContent = { "m.new_content": {} }; + const prevContent: IContent = {}; + attachMentions("@alice:test", content, model, undefined, prevContent); + expect(content).toEqual({ + "org.matrix.msc3952.mentions": {}, + "m.new_content": { "org.matrix.msc3952.mentions": {} }, + }); + }); + + it("mentions do not propagate", () => { + const model = new EditorModel([], partsCreator); + const content: IContent = { "m.new_content": {} }; + const prevContent: IContent = { + "org.matrix.msc3952.mentions": { user_ids: ["@bob:test"], room: true }, + }; + attachMentions("@alice:test", content, model, undefined, prevContent); + expect(content).toEqual({ + "org.matrix.msc3952.mentions": {}, + "m.new_content": { "org.matrix.msc3952.mentions": {} }, + }); + }); + + it("test user mentions", () => { + const model = new EditorModel([partsCreator.userPill("Bob", "@bob:test")], partsCreator); + const content: IContent = { "m.new_content": {} }; + const prevContent: IContent = {}; + attachMentions("@alice:test", content, model, undefined, prevContent); + expect(content).toEqual({ + "org.matrix.msc3952.mentions": { user_ids: ["@bob:test"] }, + "m.new_content": { "org.matrix.msc3952.mentions": { user_ids: ["@bob:test"] } }, + }); + }); + + it("test prev user mentions", () => { + const model = new EditorModel([partsCreator.userPill("Bob", "@bob:test")], partsCreator); + const content: IContent = { "m.new_content": {} }; + const prevContent: IContent = { "org.matrix.msc3952.mentions": { user_ids: ["@bob:test"] } }; + attachMentions("@alice:test", content, model, undefined, prevContent); + expect(content).toEqual({ + "org.matrix.msc3952.mentions": {}, + "m.new_content": { "org.matrix.msc3952.mentions": { user_ids: ["@bob:test"] } }, + }); + }); + + it("test room mention", () => { + const model = new EditorModel([partsCreator.atRoomPill("@room")], partsCreator); + const content: IContent = { "m.new_content": {} }; + const prevContent: IContent = {}; + attachMentions("@alice:test", content, model, undefined, prevContent); + expect(content).toEqual({ + "org.matrix.msc3952.mentions": { room: true }, + "m.new_content": { "org.matrix.msc3952.mentions": { room: true } }, + }); + }); + + it("test prev room mention", () => { + const model = new EditorModel([partsCreator.atRoomPill("@room")], partsCreator); + const content: IContent = { "m.new_content": {} }; + const prevContent: IContent = { "org.matrix.msc3952.mentions": { room: true } }; + attachMentions("@alice:test", content, model, undefined, prevContent); + expect(content).toEqual({ + "org.matrix.msc3952.mentions": {}, + "m.new_content": { "org.matrix.msc3952.mentions": { room: true } }, + }); + }); + + it("test broken mentions", () => { + // Replying to a room mention shouldn't automatically be a room mention. + const model = new EditorModel([], partsCreator); + const content: IContent = { "m.new_content": {} }; + // @ts-ignore - Purposefully testing invalid data. + const prevContent: IContent = { "org.matrix.msc3952.mentions": { user_ids: "@bob:test" } }; + attachMentions("@alice:test", content, model, undefined, prevContent); + expect(content).toEqual({ + "org.matrix.msc3952.mentions": {}, + "m.new_content": { "org.matrix.msc3952.mentions": {} }, + }); + }); + }); + }); + describe("functions correctly mounted", () => { const mockClient = createTestClient(); jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient); diff --git a/test/components/views/rooms/VoiceRecordComposerTile-test.tsx b/test/components/views/rooms/VoiceRecordComposerTile-test.tsx index 6e7c24576f..eedf874117 100644 --- a/test/components/views/rooms/VoiceRecordComposerTile-test.tsx +++ b/test/components/views/rooms/VoiceRecordComposerTile-test.tsx @@ -26,6 +26,8 @@ import { IUpload, VoiceMessageRecording } from "../../../../src/audio/VoiceMessa import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; import { VoiceRecordingStore } from "../../../../src/stores/VoiceRecordingStore"; import { PlaybackClock } from "../../../../src/audio/PlaybackClock"; +import { mkEvent } from "../../../test-utils"; +import SettingsStore from "../../../../src/settings/SettingsStore"; jest.mock("../../../../src/utils/local-room", () => ({ doMaybeLocalRoomAction: jest.fn(), @@ -50,6 +52,7 @@ describe("", () => { beforeEach(() => { mockClient = { + getSafeUserId: jest.fn().mockReturnValue("@alice:example.com"), sendMessage: jest.fn(), } as unknown as MatrixClient; MatrixClientPeg.get = () => mockClient; @@ -99,6 +102,10 @@ describe("", () => { return fn(roomId); }, ); + + jest.spyOn(SettingsStore, "getValue").mockImplementation( + (settingName) => settingName === "feature_intentional_mentions", + ); }); describe("send", () => { @@ -127,6 +134,61 @@ describe("", () => { "org.matrix.msc1767.text": "Voice message", "org.matrix.msc3245.voice": {}, "url": "mxc://example.com/voice", + "org.matrix.msc3952.mentions": {}, + }); + }); + + it("reply with voice recording", async () => { + const room = { + roomId, + } as unknown as Room; + + const replyToEvent = mkEvent({ + type: "m.room.message", + user: "@bob:test", + room: roomId, + content: {}, + event: true, + }); + + const props = { + room, + ref: voiceRecordComposerTile, + permalinkCreator: new RoomPermalinkCreator(room), + replyToEvent, + }; + render(); + + await voiceRecordComposerTile.current!.send(); + expect(mockClient.sendMessage).toHaveBeenCalledWith(roomId, { + "body": "Voice message", + "file": undefined, + "info": { + duration: 1337000, + mimetype: "audio/ogg", + size: undefined, + }, + "msgtype": MsgType.Audio, + "org.matrix.msc1767.audio": { + duration: 1337000, + waveform: [1434, 2560, 3686], + }, + "org.matrix.msc1767.file": { + file: undefined, + mimetype: "audio/ogg", + name: "Voice message.ogg", + size: undefined, + url: "mxc://example.com/voice", + }, + "org.matrix.msc1767.text": "Voice message", + "org.matrix.msc3245.voice": {}, + "url": "mxc://example.com/voice", + "m.relates_to": { + "m.in_reply_to": { + event_id: replyToEvent.getId(), + }, + }, + "org.matrix.msc3952.mentions": { user_ids: ["@bob:test"] }, }); }); });