Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Implement MSC3952: intentional mentions #9983

Merged
merged 23 commits into from
Mar 23, 2023
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
71658a5
Proof of concept for adding mentions data.
clokep Jan 24, 2023
1967841
Handle message replies.
clokep Mar 1, 2023
a654d1e
Handle message edits.
clokep Mar 7, 2023
bd1d5ec
Handle replying with a file.
clokep Mar 8, 2023
e991079
Handle replying with a voice recording.
clokep Mar 8, 2023
23fed51
Fix red-highlight for message edits.
clokep Mar 14, 2023
e92cd91
Add a labs flag for intentional mentions.
clokep Mar 14, 2023
bc99e11
Use getSafeUserId() instead of getUserId()!.
clokep Mar 15, 2023
92b0941
Fix typo.
clokep Mar 15, 2023
6b8be0c
Attach mentions for some slash commands.
clokep Mar 15, 2023
0a853e2
Add a note about WYSIWYG.
clokep Mar 15, 2023
82e132b
Fix-up tests for getSafeUserId.
clokep Mar 15, 2023
edc1eeb
Handle editing in attachMentions.
clokep Mar 15, 2023
a31fccb
Key feature on server support.
clokep Mar 16, 2023
7dec860
Revert "Fix red-highlight for message edits."
clokep Mar 17, 2023
99933a5
Merge remote-tracking branch 'upstream/develop' into intentional-ment…
clokep Mar 17, 2023
b46865f
Pipe through intentional mentions option.
clokep Mar 17, 2023
93fd79a
FIx broken tests.
clokep Mar 22, 2023
77d18a4
Merge remote-tracking branch 'upstream/develop' into intentional-ment…
clokep Mar 22, 2023
9cc18f5
Fix-up conflict from upstream.
clokep Mar 22, 2023
9afe1c0
Pass proper body when editing.
clokep Mar 22, 2023
5a2ac39
Merge remote-tracking branch 'upstream/develop' into intentional-ment…
clokep Mar 23, 2023
6fbb02e
Merge branch 'develop' into intentional-mentions
clokep Mar 23, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/ContentMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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, {
Expand Down
2 changes: 2 additions & 0 deletions src/MatrixClientPeg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
25 changes: 13 additions & 12 deletions src/components/views/rooms/EditMessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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, {
Expand All @@ -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 property for the *new* content (as if there was no edit).
//
// 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, newContent, model, undefined, editedEvent.getContent());
}
attachRelation(contentBody, { rel_type: "m.replace", event_id: editedEvent.getId() });

return contentBody;
}

interface IEditMessageComposerProps extends MatrixClientProps {
Expand Down
107 changes: 105 additions & 2 deletions src/components/views/rooms/SendMessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand All @@ -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<string>();
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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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, {
Expand Down Expand Up @@ -381,6 +481,8 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
}

if (cmd.category === CommandCategories.messages || cmd.category === CommandCategories.effects) {
// Attach any mentions which might be contained in the command content.
attachMentions(this.props.mxClient.getSafeUserId(), content, model, replyToEvent);
attachRelation(content, this.props.relation);
if (replyToEvent) {
addReplyToMessageContent(content, replyToEvent, {
Expand Down Expand Up @@ -413,6 +515,7 @@ export class SendMessageComposer extends React.Component<ISendMessageComposerPro
const { roomId } = this.props.room;
if (!content) {
content = createMessageContent(
this.props.mxClient.getSafeUserId(),
model,
replyToEvent,
this.props.relation,
Expand Down
4 changes: 3 additions & 1 deletion src/components/views/rooms/VoiceRecordComposerTile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import InlineSpinner from "../elements/InlineSpinner";
import { PlaybackManager } from "../../../audio/PlaybackManager";
import { doMaybeLocalRoomAction } from "../../../utils/local-room";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { attachRelation } from "./SendMessageComposer";
import { attachMentions, attachRelation } from "./SendMessageComposer";
import { addReplyToMessageContent } from "../../../utils/Reply";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import RoomContext from "../../../contexts/RoomContext";
Expand Down Expand Up @@ -129,6 +129,8 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
this.state.recorder.getPlayback().thumbnailWaveform.map((v) => 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, {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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)",
Expand Down
11 changes: 11 additions & 0 deletions src/settings/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -540,6 +540,17 @@ export const SETTINGS: { [setting: string]: ISetting } = {
labsGroup: LabGroup.Rooms,
default: false,
},
// MSC3952 intentional mentions support.
clokep marked this conversation as resolved.
Show resolved Hide resolved
"feature_intentional_mentions": {
clokep marked this conversation as resolved.
Show resolved Hide resolved
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"),
Expand Down
32 changes: 31 additions & 1 deletion test/ContentMessages-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({}) }));

Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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", () => {
Expand Down
Loading