Default intentional mentions (#11602)
* Default intentional mentions * wait for autocomplete to settle before submitting edit * lint * Update strings --------- Co-authored-by: Kerry Archibald <kerrya@element.io>
This commit is contained in:
parent
237038aa56
commit
3608d52c4a
9 changed files with 282 additions and 349 deletions
|
@ -90,10 +90,9 @@ export function attachMentions(
|
||||||
replyToEvent: MatrixEvent | undefined,
|
replyToEvent: MatrixEvent | undefined,
|
||||||
editedContent: IContent | null = null,
|
editedContent: IContent | null = null,
|
||||||
): void {
|
): void {
|
||||||
// If this feature is disabled, do nothing.
|
// We always attach the mentions even if the home server doesn't yet support
|
||||||
if (!SettingsStore.getValue("feature_intentional_mentions")) {
|
// intentional mentions. This is safe because m.mentions is an additive change
|
||||||
return;
|
// that should simply be ignored by incapable home servers.
|
||||||
}
|
|
||||||
|
|
||||||
// The mentions property *always* gets included to disable legacy push rules.
|
// The mentions property *always* gets included to disable legacy push rules.
|
||||||
const mentions: IMentions = (content["m.mentions"] = {});
|
const mentions: IMentions = (content["m.mentions"] = {});
|
||||||
|
|
|
@ -1151,7 +1151,6 @@
|
||||||
"voice_broadcast": "Voice broadcast",
|
"voice_broadcast": "Voice broadcast",
|
||||||
"rust_crypto": "Rust cryptography implementation",
|
"rust_crypto": "Rust cryptography implementation",
|
||||||
"hidebold": "Hide notification dot (only display counters badges)",
|
"hidebold": "Hide notification dot (only display counters badges)",
|
||||||
"intentional_mentions": "Enable intentional mentions",
|
|
||||||
"ask_to_join": "Enable ask to join",
|
"ask_to_join": "Enable ask to join",
|
||||||
"new_room_decoration_ui": "New room header & details interface",
|
"new_room_decoration_ui": "New room header & details interface",
|
||||||
"beta_feature": "This is a beta feature",
|
"beta_feature": "This is a beta feature",
|
||||||
|
|
|
@ -531,20 +531,6 @@ export const SETTINGS: { [setting: string]: ISetting } = {
|
||||||
labsGroup: LabGroup.Rooms,
|
labsGroup: LabGroup.Rooms,
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
// MSC3952 intentional mentions support.
|
|
||||||
"feature_intentional_mentions": {
|
|
||||||
isFeature: true,
|
|
||||||
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
|
|
||||||
displayName: _td("labs|intentional_mentions"),
|
|
||||||
labsGroup: LabGroup.Rooms,
|
|
||||||
default: false,
|
|
||||||
controller: new ServerSupportUnstableFeatureController(
|
|
||||||
"feature_intentional_mentions",
|
|
||||||
defaultWatchManager,
|
|
||||||
[["org.matrix.msc3952_intentional_mentions"]],
|
|
||||||
"v1.7",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
"feature_ask_to_join": {
|
"feature_ask_to_join": {
|
||||||
default: false,
|
default: false,
|
||||||
displayName: _td("labs|ask_to_join"),
|
displayName: _td("labs|ask_to_join"),
|
||||||
|
|
|
@ -23,7 +23,6 @@ import ContentMessages, { UploadCanceledError, uploadFile } from "../src/Content
|
||||||
import { doMaybeLocalRoomAction } from "../src/utils/local-room";
|
import { doMaybeLocalRoomAction } from "../src/utils/local-room";
|
||||||
import { createTestClient, mkEvent } from "./test-utils";
|
import { createTestClient, mkEvent } from "./test-utils";
|
||||||
import { BlurhashEncoder } from "../src/BlurhashEncoder";
|
import { BlurhashEncoder } from "../src/BlurhashEncoder";
|
||||||
import SettingsStore from "../src/settings/SettingsStore";
|
|
||||||
|
|
||||||
jest.mock("matrix-encrypt-attachment", () => ({ encryptAttachment: jest.fn().mockResolvedValue({}) }));
|
jest.mock("matrix-encrypt-attachment", () => ({ encryptAttachment: jest.fn().mockResolvedValue({}) }));
|
||||||
|
|
||||||
|
@ -278,10 +277,6 @@ describe("ContentMessages", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("properly handles replies", async () => {
|
it("properly handles replies", async () => {
|
||||||
jest.spyOn(SettingsStore, "getValue").mockImplementation(
|
|
||||||
(settingName) => settingName === "feature_intentional_mentions",
|
|
||||||
);
|
|
||||||
|
|
||||||
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
|
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
|
||||||
const file = new File([], "fileName", { type: "image/jpeg" });
|
const file = new File([], "fileName", { type: "image/jpeg" });
|
||||||
const replyToEvent = mkEvent({
|
const replyToEvent = mkEvent({
|
||||||
|
|
|
@ -112,6 +112,7 @@ describe("ThreadView", () => {
|
||||||
"rel_type": RelationType.Thread,
|
"rel_type": RelationType.Thread,
|
||||||
},
|
},
|
||||||
"msgtype": MsgType.Text,
|
"msgtype": MsgType.Text,
|
||||||
|
"m.mentions": {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -137,13 +137,15 @@ describe("<EditMessageComposer/>", () => {
|
||||||
...editedEvent.getContent(),
|
...editedEvent.getContent(),
|
||||||
"body": " * original message + edit",
|
"body": " * original message + edit",
|
||||||
"m.new_content": {
|
"m.new_content": {
|
||||||
body: "original message + edit",
|
"body": "original message + edit",
|
||||||
msgtype: "m.text",
|
"msgtype": "m.text",
|
||||||
|
"m.mentions": {},
|
||||||
},
|
},
|
||||||
"m.relates_to": {
|
"m.relates_to": {
|
||||||
event_id: editedEvent.getId(),
|
event_id: editedEvent.getId(),
|
||||||
rel_type: "m.replace",
|
rel_type: "m.replace",
|
||||||
},
|
},
|
||||||
|
"m.mentions": {},
|
||||||
};
|
};
|
||||||
expect(mockClient.sendMessage).toHaveBeenCalledWith(editedEvent.getRoomId()!, null, expectedBody);
|
expect(mockClient.sendMessage).toHaveBeenCalledWith(editedEvent.getRoomId()!, null, expectedBody);
|
||||||
});
|
});
|
||||||
|
@ -168,13 +170,15 @@ describe("<EditMessageComposer/>", () => {
|
||||||
"body": " * hello world",
|
"body": " * hello world",
|
||||||
"msgtype": "m.text",
|
"msgtype": "m.text",
|
||||||
"m.new_content": {
|
"m.new_content": {
|
||||||
body: "hello world",
|
"body": "hello world",
|
||||||
msgtype: "m.text",
|
"msgtype": "m.text",
|
||||||
|
"m.mentions": {},
|
||||||
},
|
},
|
||||||
"m.relates_to": {
|
"m.relates_to": {
|
||||||
event_id: editedEvent.getId(),
|
event_id: editedEvent.getId(),
|
||||||
rel_type: "m.replace",
|
rel_type: "m.replace",
|
||||||
},
|
},
|
||||||
|
"m.mentions": {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -191,15 +195,17 @@ describe("<EditMessageComposer/>", () => {
|
||||||
"format": "org.matrix.custom.html",
|
"format": "org.matrix.custom.html",
|
||||||
"formatted_body": " * hello <em>world</em>",
|
"formatted_body": " * hello <em>world</em>",
|
||||||
"m.new_content": {
|
"m.new_content": {
|
||||||
body: "hello *world*",
|
"body": "hello *world*",
|
||||||
msgtype: "m.text",
|
"msgtype": "m.text",
|
||||||
format: "org.matrix.custom.html",
|
"format": "org.matrix.custom.html",
|
||||||
formatted_body: "hello <em>world</em>",
|
"formatted_body": "hello <em>world</em>",
|
||||||
|
"m.mentions": {},
|
||||||
},
|
},
|
||||||
"m.relates_to": {
|
"m.relates_to": {
|
||||||
event_id: editedEvent.getId(),
|
event_id: editedEvent.getId(),
|
||||||
rel_type: "m.replace",
|
rel_type: "m.replace",
|
||||||
},
|
},
|
||||||
|
"m.mentions": {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -216,15 +222,17 @@ describe("<EditMessageComposer/>", () => {
|
||||||
"format": "org.matrix.custom.html",
|
"format": "org.matrix.custom.html",
|
||||||
"formatted_body": " * blinks <strong>quickly</strong>",
|
"formatted_body": " * blinks <strong>quickly</strong>",
|
||||||
"m.new_content": {
|
"m.new_content": {
|
||||||
body: "blinks __quickly__",
|
"body": "blinks __quickly__",
|
||||||
msgtype: "m.emote",
|
"msgtype": "m.emote",
|
||||||
format: "org.matrix.custom.html",
|
"format": "org.matrix.custom.html",
|
||||||
formatted_body: "blinks <strong>quickly</strong>",
|
"formatted_body": "blinks <strong>quickly</strong>",
|
||||||
|
"m.mentions": {},
|
||||||
},
|
},
|
||||||
"m.relates_to": {
|
"m.relates_to": {
|
||||||
event_id: editedEvent.getId(),
|
event_id: editedEvent.getId(),
|
||||||
rel_type: "m.replace",
|
rel_type: "m.replace",
|
||||||
},
|
},
|
||||||
|
"m.mentions": {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -240,13 +248,15 @@ describe("<EditMessageComposer/>", () => {
|
||||||
"body": " * ✨sparkles✨",
|
"body": " * ✨sparkles✨",
|
||||||
"msgtype": "m.emote",
|
"msgtype": "m.emote",
|
||||||
"m.new_content": {
|
"m.new_content": {
|
||||||
body: "✨sparkles✨",
|
"body": "✨sparkles✨",
|
||||||
msgtype: "m.emote",
|
"msgtype": "m.emote",
|
||||||
|
"m.mentions": {},
|
||||||
},
|
},
|
||||||
"m.relates_to": {
|
"m.relates_to": {
|
||||||
event_id: editedEvent.getId(),
|
event_id: editedEvent.getId(),
|
||||||
rel_type: "m.replace",
|
rel_type: "m.replace",
|
||||||
},
|
},
|
||||||
|
"m.mentions": {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -264,166 +274,246 @@ describe("<EditMessageComposer/>", () => {
|
||||||
"body": " * //dev/null is my favourite place",
|
"body": " * //dev/null is my favourite place",
|
||||||
"msgtype": "m.text",
|
"msgtype": "m.text",
|
||||||
"m.new_content": {
|
"m.new_content": {
|
||||||
body: "//dev/null is my favourite place",
|
"body": "//dev/null is my favourite place",
|
||||||
msgtype: "m.text",
|
"msgtype": "m.text",
|
||||||
|
"m.mentions": {},
|
||||||
},
|
},
|
||||||
"m.relates_to": {
|
"m.relates_to": {
|
||||||
event_id: editedEvent.getId(),
|
event_id: editedEvent.getId(),
|
||||||
rel_type: "m.replace",
|
rel_type: "m.replace",
|
||||||
},
|
},
|
||||||
|
"m.mentions": {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("with feature_intentional_mentions enabled", () => {
|
describe("when message is not a reply", () => {
|
||||||
const mockSettings = (mockValues: Record<string, unknown> = {}) => {
|
it("should attach an empty mentions object for a message with no mentions", async () => {
|
||||||
const defaultMockValues = {
|
const editState = new EditorStateTransfer(editedEvent);
|
||||||
feature_intentional_mentions: true,
|
getComponent(editState);
|
||||||
};
|
const editContent = " + edit";
|
||||||
jest.spyOn(SettingsStore, "getValue")
|
await editText(editContent);
|
||||||
.mockClear()
|
|
||||||
.mockImplementation((settingName) => {
|
|
||||||
return { ...defaultMockValues, ...mockValues }[settingName];
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(() => {
|
fireEvent.click(screen.getByText("Save"));
|
||||||
mockSettings();
|
|
||||||
|
const messageContent = mockClient.sendMessage.mock.calls[0][2];
|
||||||
|
|
||||||
|
// both content.mentions and new_content.mentions are empty
|
||||||
|
expect(messageContent["m.mentions"]).toEqual({});
|
||||||
|
expect(messageContent["m.new_content"]["m.mentions"]).toEqual({});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("when message is not a reply", () => {
|
it("should retain mentions in the original message that are not removed by the edit", async () => {
|
||||||
it("should attach an empty mentions object for a message with no mentions", async () => {
|
const editState = new EditorStateTransfer(eventWithMentions);
|
||||||
const editState = new EditorStateTransfer(editedEvent);
|
getComponent(editState);
|
||||||
getComponent(editState);
|
// Remove charlie from the message
|
||||||
const editContent = " + edit";
|
const editContent = "{backspace}{backspace}friends";
|
||||||
await editText(editContent);
|
await editText(editContent);
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Save"));
|
fireEvent.click(screen.getByText("Save"));
|
||||||
|
|
||||||
const messageContent = mockClient.sendMessage.mock.calls[0][2];
|
const messageContent = mockClient.sendMessage.mock.calls[0][2];
|
||||||
|
|
||||||
// both content.mentions and new_content.mentions are empty
|
// no new mentions were added, so nothing in top level mentions
|
||||||
expect(messageContent["m.mentions"]).toEqual({});
|
expect(messageContent["m.mentions"]).toEqual({});
|
||||||
expect(messageContent["m.new_content"]["m.mentions"]).toEqual({});
|
// bob is still mentioned, charlie removed
|
||||||
});
|
expect(messageContent["m.new_content"]["m.mentions"]).toEqual({
|
||||||
|
user_ids: ["@bob:server.org"],
|
||||||
it("should retain mentions in the original message that are not removed by the edit", async () => {
|
|
||||||
const editState = new EditorStateTransfer(eventWithMentions);
|
|
||||||
getComponent(editState);
|
|
||||||
// Remove charlie from the message
|
|
||||||
const editContent = "{backspace}{backspace}friends";
|
|
||||||
await editText(editContent);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Save"));
|
|
||||||
|
|
||||||
const messageContent = mockClient.sendMessage.mock.calls[0][2];
|
|
||||||
|
|
||||||
// no new mentions were added, so nothing in top level mentions
|
|
||||||
expect(messageContent["m.mentions"]).toEqual({});
|
|
||||||
// bob is still mentioned, charlie removed
|
|
||||||
expect(messageContent["m.new_content"]["m.mentions"]).toEqual({
|
|
||||||
user_ids: ["@bob:server.org"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should remove mentions that are removed by the edit", async () => {
|
|
||||||
const editState = new EditorStateTransfer(eventWithMentions);
|
|
||||||
getComponent(editState);
|
|
||||||
const editContent = "new message!";
|
|
||||||
// clear the original message
|
|
||||||
await editText(editContent, true);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Save"));
|
|
||||||
|
|
||||||
const messageContent = mockClient.sendMessage.mock.calls[0][2];
|
|
||||||
|
|
||||||
// no new mentions were added, so nothing in top level mentions
|
|
||||||
expect(messageContent["m.mentions"]).toEqual({});
|
|
||||||
// bob is not longer mentioned in the edited message, so empty mentions in new_content
|
|
||||||
expect(messageContent["m.new_content"]["m.mentions"]).toEqual({});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add mentions that were added in the edit", async () => {
|
|
||||||
const editState = new EditorStateTransfer(editedEvent);
|
|
||||||
getComponent(editState);
|
|
||||||
const editContent = " and @d";
|
|
||||||
await editText(editContent);
|
|
||||||
|
|
||||||
// submit autocomplete for mention
|
|
||||||
await editText("{enter}");
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Save"));
|
|
||||||
|
|
||||||
const messageContent = mockClient.sendMessage.mock.calls[0][2];
|
|
||||||
|
|
||||||
// new mention in the edit
|
|
||||||
expect(messageContent["m.mentions"]).toEqual({
|
|
||||||
user_ids: ["@dan:server.org"],
|
|
||||||
});
|
|
||||||
expect(messageContent["m.new_content"]["m.mentions"]).toEqual({
|
|
||||||
user_ids: ["@dan:server.org"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should add and remove mentions from the edit", async () => {
|
|
||||||
const editState = new EditorStateTransfer(eventWithMentions);
|
|
||||||
getComponent(editState);
|
|
||||||
// Remove charlie from the message
|
|
||||||
await editText("{backspace}{backspace}");
|
|
||||||
// and replace with @room
|
|
||||||
await editText("@d");
|
|
||||||
// submit autocomplete for @dan mention
|
|
||||||
await editText("{enter}");
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Save"));
|
|
||||||
|
|
||||||
const messageContent = mockClient.sendMessage.mock.calls[0][2];
|
|
||||||
|
|
||||||
// new mention in the edit
|
|
||||||
expect(messageContent["m.mentions"]).toEqual({
|
|
||||||
user_ids: ["@dan:server.org"],
|
|
||||||
});
|
|
||||||
// all mentions in the edited version of the event
|
|
||||||
expect(messageContent["m.new_content"]["m.mentions"]).toEqual({
|
|
||||||
user_ids: ["@bob:server.org", "@dan:server.org"],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("when message is replying", () => {
|
it("should remove mentions that are removed by the edit", async () => {
|
||||||
const originalEvent = mkEvent({
|
const editState = new EditorStateTransfer(eventWithMentions);
|
||||||
type: "m.room.message",
|
getComponent(editState);
|
||||||
user: "@ernie:test",
|
const editContent = "new message!";
|
||||||
room: roomId,
|
// clear the original message
|
||||||
content: { body: "original message", msgtype: "m.text" },
|
await editText(editContent, true);
|
||||||
event: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const replyEvent = mkEvent({
|
fireEvent.click(screen.getByText("Save"));
|
||||||
type: "m.room.message",
|
|
||||||
user: "@bert:test",
|
const messageContent = mockClient.sendMessage.mock.calls[0][2];
|
||||||
room: roomId,
|
|
||||||
content: {
|
// no new mentions were added, so nothing in top level mentions
|
||||||
"body": "reply with plain message",
|
expect(messageContent["m.mentions"]).toEqual({});
|
||||||
"msgtype": "m.text",
|
// bob is not longer mentioned in the edited message, so empty mentions in new_content
|
||||||
"m.relates_to": {
|
expect(messageContent["m.new_content"]["m.mentions"]).toEqual({});
|
||||||
"m.in_reply_to": {
|
});
|
||||||
event_id: originalEvent.getId(),
|
|
||||||
},
|
it("should add mentions that were added in the edit", async () => {
|
||||||
},
|
const editState = new EditorStateTransfer(editedEvent);
|
||||||
"m.mentions": {
|
getComponent(editState);
|
||||||
user_ids: [originalEvent.getSender()!],
|
const editContent = " and @d";
|
||||||
|
await editText(editContent);
|
||||||
|
|
||||||
|
// wait for autocompletion to render
|
||||||
|
await screen.findByText("Dan");
|
||||||
|
// submit autocomplete for mention
|
||||||
|
await editText("{enter}");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Save"));
|
||||||
|
|
||||||
|
const messageContent = mockClient.sendMessage.mock.calls[0][2];
|
||||||
|
|
||||||
|
// new mention in the edit
|
||||||
|
expect(messageContent["m.mentions"]).toEqual({
|
||||||
|
user_ids: ["@dan:server.org"],
|
||||||
|
});
|
||||||
|
expect(messageContent["m.new_content"]["m.mentions"]).toEqual({
|
||||||
|
user_ids: ["@dan:server.org"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add and remove mentions from the edit", async () => {
|
||||||
|
const editState = new EditorStateTransfer(eventWithMentions);
|
||||||
|
getComponent(editState);
|
||||||
|
// Remove charlie from the message
|
||||||
|
await editText("{backspace}{backspace}");
|
||||||
|
// and replace with @room
|
||||||
|
await editText("@d");
|
||||||
|
// wait for autocompletion to render
|
||||||
|
await screen.findByText("Dan");
|
||||||
|
// submit autocomplete for @dan mention
|
||||||
|
await editText("{enter}");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Save"));
|
||||||
|
|
||||||
|
const messageContent = mockClient.sendMessage.mock.calls[0][2];
|
||||||
|
|
||||||
|
// new mention in the edit
|
||||||
|
expect(messageContent["m.mentions"]).toEqual({
|
||||||
|
user_ids: ["@dan:server.org"],
|
||||||
|
});
|
||||||
|
// all mentions in the edited version of the event
|
||||||
|
expect(messageContent["m.new_content"]["m.mentions"]).toEqual({
|
||||||
|
user_ids: ["@bob:server.org", "@dan:server.org"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("when message is replying", () => {
|
||||||
|
const originalEvent = mkEvent({
|
||||||
|
type: "m.room.message",
|
||||||
|
user: "@ernie:test",
|
||||||
|
room: roomId,
|
||||||
|
content: { body: "original message", msgtype: "m.text" },
|
||||||
|
event: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const replyEvent = mkEvent({
|
||||||
|
type: "m.room.message",
|
||||||
|
user: "@bert:test",
|
||||||
|
room: roomId,
|
||||||
|
content: {
|
||||||
|
"body": "reply with plain message",
|
||||||
|
"msgtype": "m.text",
|
||||||
|
"m.relates_to": {
|
||||||
|
"m.in_reply_to": {
|
||||||
|
event_id: originalEvent.getId(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
event: true,
|
"m.mentions": {
|
||||||
});
|
user_ids: [originalEvent.getSender()!],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
event: true,
|
||||||
|
});
|
||||||
|
|
||||||
const replyWithMentions = mkEvent({
|
const replyWithMentions = mkEvent({
|
||||||
|
type: "m.room.message",
|
||||||
|
user: "@bert:test",
|
||||||
|
room: roomId,
|
||||||
|
content: {
|
||||||
|
"body": 'reply that mentions <a href="https://matrix.to/#/@bob:server.org">Bob</a>',
|
||||||
|
"msgtype": "m.text",
|
||||||
|
"m.relates_to": {
|
||||||
|
"m.in_reply_to": {
|
||||||
|
event_id: originalEvent.getId(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"m.mentions": {
|
||||||
|
user_ids: [
|
||||||
|
// sender of event we replied to
|
||||||
|
originalEvent.getSender()!,
|
||||||
|
// mentions from this event
|
||||||
|
"@bob:server.org",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
event: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setupRoomWithEventsTimeline(room, [originalEvent, replyEvent]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should retain parent event sender in mentions when editing with plain text", async () => {
|
||||||
|
const editState = new EditorStateTransfer(replyEvent);
|
||||||
|
getComponent(editState);
|
||||||
|
const editContent = " + edit";
|
||||||
|
await editText(editContent);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Save"));
|
||||||
|
|
||||||
|
const messageContent = mockClient.sendMessage.mock.calls[0][2];
|
||||||
|
|
||||||
|
// no new mentions from edit
|
||||||
|
expect(messageContent["m.mentions"]).toEqual({});
|
||||||
|
// edited reply still mentions the parent event sender
|
||||||
|
expect(messageContent["m.new_content"]["m.mentions"]).toEqual({
|
||||||
|
user_ids: [originalEvent.getSender()],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should retain parent event sender in mentions when adding a mention", async () => {
|
||||||
|
const editState = new EditorStateTransfer(replyEvent);
|
||||||
|
getComponent(editState);
|
||||||
|
await editText(" and @d");
|
||||||
|
// wait for autocompletion to render
|
||||||
|
await screen.findByText("Dan");
|
||||||
|
// submit autocomplete for @dan mention
|
||||||
|
await editText("{enter}");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Save"));
|
||||||
|
|
||||||
|
const messageContent = mockClient.sendMessage.mock.calls[0][2];
|
||||||
|
|
||||||
|
// new mention in edit
|
||||||
|
expect(messageContent["m.mentions"]).toEqual({
|
||||||
|
user_ids: ["@dan:server.org"],
|
||||||
|
});
|
||||||
|
// edited reply still mentions the parent event sender
|
||||||
|
// plus new mention @dan
|
||||||
|
expect(messageContent["m.new_content"]["m.mentions"]).toEqual({
|
||||||
|
user_ids: [originalEvent.getSender(), "@dan:server.org"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should retain parent event sender in mentions when removing all mentions from content", async () => {
|
||||||
|
const editState = new EditorStateTransfer(replyWithMentions);
|
||||||
|
getComponent(editState);
|
||||||
|
// replace text to remove all mentions
|
||||||
|
await editText("no mentions here", true);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Save"));
|
||||||
|
|
||||||
|
const messageContent = mockClient.sendMessage.mock.calls[0][2];
|
||||||
|
|
||||||
|
// no mentions in edit
|
||||||
|
expect(messageContent["m.mentions"]).toEqual({});
|
||||||
|
// edited reply still mentions the parent event sender
|
||||||
|
// existing @bob mention removed
|
||||||
|
expect(messageContent["m.new_content"]["m.mentions"]).toEqual({
|
||||||
|
user_ids: [originalEvent.getSender()],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should retain parent event sender in mentions when removing mention of said user", async () => {
|
||||||
|
const replyThatMentionsParentEventSender = mkEvent({
|
||||||
type: "m.room.message",
|
type: "m.room.message",
|
||||||
user: "@bert:test",
|
user: "@bert:test",
|
||||||
room: roomId,
|
room: roomId,
|
||||||
content: {
|
content: {
|
||||||
"body": 'reply that mentions <a href="https://matrix.to/#/@bob:server.org">Bob</a>',
|
"body": `reply that mentions the sender of the message we replied to <a href="https://matrix.to/#/${originalEvent.getSender()!}">Ernie</a>`,
|
||||||
"msgtype": "m.text",
|
"msgtype": "m.text",
|
||||||
"m.relates_to": {
|
"m.relates_to": {
|
||||||
"m.in_reply_to": {
|
"m.in_reply_to": {
|
||||||
|
@ -434,114 +524,25 @@ describe("<EditMessageComposer/>", () => {
|
||||||
user_ids: [
|
user_ids: [
|
||||||
// sender of event we replied to
|
// sender of event we replied to
|
||||||
originalEvent.getSender()!,
|
originalEvent.getSender()!,
|
||||||
// mentions from this event
|
|
||||||
"@bob:server.org",
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
event: true,
|
event: true,
|
||||||
});
|
});
|
||||||
|
const editState = new EditorStateTransfer(replyThatMentionsParentEventSender);
|
||||||
|
getComponent(editState);
|
||||||
|
// replace text to remove all mentions
|
||||||
|
await editText("no mentions here", true);
|
||||||
|
|
||||||
beforeEach(() => {
|
fireEvent.click(screen.getByText("Save"));
|
||||||
setupRoomWithEventsTimeline(room, [originalEvent, replyEvent]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should retain parent event sender in mentions when editing with plain text", async () => {
|
const messageContent = mockClient.sendMessage.mock.calls[0][2];
|
||||||
const editState = new EditorStateTransfer(replyEvent);
|
|
||||||
getComponent(editState);
|
|
||||||
const editContent = " + edit";
|
|
||||||
await editText(editContent);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Save"));
|
// no mentions in edit
|
||||||
|
expect(messageContent["m.mentions"]).toEqual({});
|
||||||
const messageContent = mockClient.sendMessage.mock.calls[0][2];
|
// edited reply still mentions the parent event sender
|
||||||
|
expect(messageContent["m.new_content"]["m.mentions"]).toEqual({
|
||||||
// no new mentions from edit
|
user_ids: [originalEvent.getSender()],
|
||||||
expect(messageContent["m.mentions"]).toEqual({});
|
|
||||||
// edited reply still mentions the parent event sender
|
|
||||||
expect(messageContent["m.new_content"]["m.mentions"]).toEqual({
|
|
||||||
user_ids: [originalEvent.getSender()],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should retain parent event sender in mentions when adding a mention", async () => {
|
|
||||||
const editState = new EditorStateTransfer(replyEvent);
|
|
||||||
getComponent(editState);
|
|
||||||
await editText(" and @d");
|
|
||||||
// submit autocomplete for @dan mention
|
|
||||||
await editText("{enter}");
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Save"));
|
|
||||||
|
|
||||||
const messageContent = mockClient.sendMessage.mock.calls[0][2];
|
|
||||||
|
|
||||||
// new mention in edit
|
|
||||||
expect(messageContent["m.mentions"]).toEqual({
|
|
||||||
user_ids: ["@dan:server.org"],
|
|
||||||
});
|
|
||||||
// edited reply still mentions the parent event sender
|
|
||||||
// plus new mention @dan
|
|
||||||
expect(messageContent["m.new_content"]["m.mentions"]).toEqual({
|
|
||||||
user_ids: [originalEvent.getSender(), "@dan:server.org"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should retain parent event sender in mentions when removing all mentions from content", async () => {
|
|
||||||
const editState = new EditorStateTransfer(replyWithMentions);
|
|
||||||
getComponent(editState);
|
|
||||||
// replace text to remove all mentions
|
|
||||||
await editText("no mentions here", true);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Save"));
|
|
||||||
|
|
||||||
const messageContent = mockClient.sendMessage.mock.calls[0][2];
|
|
||||||
|
|
||||||
// no mentions in edit
|
|
||||||
expect(messageContent["m.mentions"]).toEqual({});
|
|
||||||
// edited reply still mentions the parent event sender
|
|
||||||
// existing @bob mention removed
|
|
||||||
expect(messageContent["m.new_content"]["m.mentions"]).toEqual({
|
|
||||||
user_ids: [originalEvent.getSender()],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should retain parent event sender in mentions when removing mention of said user", async () => {
|
|
||||||
const replyThatMentionsParentEventSender = mkEvent({
|
|
||||||
type: "m.room.message",
|
|
||||||
user: "@bert:test",
|
|
||||||
room: roomId,
|
|
||||||
content: {
|
|
||||||
"body": `reply that mentions the sender of the message we replied to <a href="https://matrix.to/#/${originalEvent.getSender()!}">Ernie</a>`,
|
|
||||||
"msgtype": "m.text",
|
|
||||||
"m.relates_to": {
|
|
||||||
"m.in_reply_to": {
|
|
||||||
event_id: originalEvent.getId(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"m.mentions": {
|
|
||||||
user_ids: [
|
|
||||||
// sender of event we replied to
|
|
||||||
originalEvent.getSender()!,
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
event: true,
|
|
||||||
});
|
|
||||||
const editState = new EditorStateTransfer(replyThatMentionsParentEventSender);
|
|
||||||
getComponent(editState);
|
|
||||||
// replace text to remove all mentions
|
|
||||||
await editText("no mentions here", true);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText("Save"));
|
|
||||||
|
|
||||||
const messageContent = mockClient.sendMessage.mock.calls[0][2];
|
|
||||||
|
|
||||||
// no mentions in edit
|
|
||||||
expect(messageContent["m.mentions"]).toEqual({});
|
|
||||||
// edited reply still mentions the parent event sender
|
|
||||||
expect(messageContent["m.new_content"]["m.mentions"]).toEqual({
|
|
||||||
user_ids: [originalEvent.getSender()],
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -39,7 +39,6 @@ import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalink
|
||||||
import { mockPlatformPeg } from "../../../test-utils/platform";
|
import { mockPlatformPeg } from "../../../test-utils/platform";
|
||||||
import { doMaybeLocalRoomAction } from "../../../../src/utils/local-room";
|
import { doMaybeLocalRoomAction } from "../../../../src/utils/local-room";
|
||||||
import { addTextToComposer } from "../../../test-utils/composer";
|
import { addTextToComposer } from "../../../test-utils/composer";
|
||||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
|
||||||
|
|
||||||
jest.mock("../../../../src/utils/local-room", () => ({
|
jest.mock("../../../../src/utils/local-room", () => ({
|
||||||
doMaybeLocalRoomAction: jest.fn(),
|
doMaybeLocalRoomAction: jest.fn(),
|
||||||
|
@ -97,8 +96,9 @@ describe("<SendMessageComposer/>", () => {
|
||||||
const content = createMessageContent("@alice:test", model, undefined, undefined, permalinkCreator);
|
const content = createMessageContent("@alice:test", model, undefined, undefined, permalinkCreator);
|
||||||
|
|
||||||
expect(content).toEqual({
|
expect(content).toEqual({
|
||||||
body: "hello world",
|
"body": "hello world",
|
||||||
msgtype: "m.text",
|
"msgtype": "m.text",
|
||||||
|
"m.mentions": {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -110,10 +110,11 @@ describe("<SendMessageComposer/>", () => {
|
||||||
const content = createMessageContent("@alice:test", model, undefined, undefined, permalinkCreator);
|
const content = createMessageContent("@alice:test", model, undefined, undefined, permalinkCreator);
|
||||||
|
|
||||||
expect(content).toEqual({
|
expect(content).toEqual({
|
||||||
body: "hello *world*",
|
"body": "hello *world*",
|
||||||
msgtype: "m.text",
|
"msgtype": "m.text",
|
||||||
format: "org.matrix.custom.html",
|
"format": "org.matrix.custom.html",
|
||||||
formatted_body: "hello <em>world</em>",
|
"formatted_body": "hello <em>world</em>",
|
||||||
|
"m.mentions": {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -125,10 +126,11 @@ describe("<SendMessageComposer/>", () => {
|
||||||
const content = createMessageContent("@alice:test", model, undefined, undefined, permalinkCreator);
|
const content = createMessageContent("@alice:test", model, undefined, undefined, permalinkCreator);
|
||||||
|
|
||||||
expect(content).toEqual({
|
expect(content).toEqual({
|
||||||
body: "blinks __quickly__",
|
"body": "blinks __quickly__",
|
||||||
msgtype: "m.emote",
|
"msgtype": "m.emote",
|
||||||
format: "org.matrix.custom.html",
|
"format": "org.matrix.custom.html",
|
||||||
formatted_body: "blinks <strong>quickly</strong>",
|
"formatted_body": "blinks <strong>quickly</strong>",
|
||||||
|
"m.mentions": {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -141,8 +143,9 @@ describe("<SendMessageComposer/>", () => {
|
||||||
const content = createMessageContent("@alice:test", model, undefined, undefined, permalinkCreator);
|
const content = createMessageContent("@alice:test", model, undefined, undefined, permalinkCreator);
|
||||||
|
|
||||||
expect(content).toEqual({
|
expect(content).toEqual({
|
||||||
body: "✨sparkles✨",
|
"body": "✨sparkles✨",
|
||||||
msgtype: "m.emote",
|
"msgtype": "m.emote",
|
||||||
|
"m.mentions": {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -155,23 +158,14 @@ describe("<SendMessageComposer/>", () => {
|
||||||
const content = createMessageContent("@alice:test", model, undefined, undefined, permalinkCreator);
|
const content = createMessageContent("@alice:test", model, undefined, undefined, permalinkCreator);
|
||||||
|
|
||||||
expect(content).toEqual({
|
expect(content).toEqual({
|
||||||
body: "/dev/null is my favourite place",
|
"body": "/dev/null is my favourite place",
|
||||||
msgtype: "m.text",
|
"msgtype": "m.text",
|
||||||
|
"m.mentions": {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("attachMentions", () => {
|
describe("attachMentions", () => {
|
||||||
beforeEach(() => {
|
|
||||||
jest.spyOn(SettingsStore, "getValue").mockImplementation(
|
|
||||||
(settingName) => settingName === "feature_intentional_mentions",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
jest.spyOn(SettingsStore, "getValue").mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
const partsCreator = createPartCreator();
|
const partsCreator = createPartCreator();
|
||||||
|
|
||||||
it("no mentions", () => {
|
it("no mentions", () => {
|
||||||
|
@ -488,8 +482,9 @@ describe("<SendMessageComposer/>", () => {
|
||||||
fireEvent.keyDown(container.querySelector(".mx_SendMessageComposer")!, { key: "Enter" });
|
fireEvent.keyDown(container.querySelector(".mx_SendMessageComposer")!, { key: "Enter" });
|
||||||
|
|
||||||
expect(mockClient.sendMessage).toHaveBeenCalledWith("myfakeroom", null, {
|
expect(mockClient.sendMessage).toHaveBeenCalledWith("myfakeroom", null, {
|
||||||
body: "test message",
|
"body": "test message",
|
||||||
msgtype: MsgType.Text,
|
"msgtype": MsgType.Text,
|
||||||
|
"m.mentions": {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -507,8 +502,9 @@ describe("<SendMessageComposer/>", () => {
|
||||||
fireEvent.keyDown(container.querySelector(".mx_SendMessageComposer")!, { key: "Enter" });
|
fireEvent.keyDown(container.querySelector(".mx_SendMessageComposer")!, { key: "Enter" });
|
||||||
|
|
||||||
expect(mockClient.sendMessage).toHaveBeenCalledWith("myfakeroom", null, {
|
expect(mockClient.sendMessage).toHaveBeenCalledWith("myfakeroom", null, {
|
||||||
body: "test message",
|
"body": "test message",
|
||||||
msgtype: MsgType.Text,
|
"msgtype": MsgType.Text,
|
||||||
|
"m.mentions": {},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: `effects.confetti` });
|
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: `effects.confetti` });
|
||||||
|
@ -534,8 +530,9 @@ describe("<SendMessageComposer/>", () => {
|
||||||
fireEvent.keyDown(container.querySelector(".mx_SendMessageComposer")!, { key: "Enter" });
|
fireEvent.keyDown(container.querySelector(".mx_SendMessageComposer")!, { key: "Enter" });
|
||||||
|
|
||||||
expect(mockClient.sendMessage).toHaveBeenCalledWith("myfakeroom", null, {
|
expect(mockClient.sendMessage).toHaveBeenCalledWith("myfakeroom", null, {
|
||||||
body: "test message",
|
"body": "test message",
|
||||||
msgtype: MsgType.Text,
|
"msgtype": MsgType.Text,
|
||||||
|
"m.mentions": {},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(defaultDispatcher.dispatch).not.toHaveBeenCalledWith({ action: `effects.confetti` });
|
expect(defaultDispatcher.dispatch).not.toHaveBeenCalledWith({ action: `effects.confetti` });
|
||||||
|
|
|
@ -27,7 +27,6 @@ import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalink
|
||||||
import { VoiceRecordingStore } from "../../../../src/stores/VoiceRecordingStore";
|
import { VoiceRecordingStore } from "../../../../src/stores/VoiceRecordingStore";
|
||||||
import { PlaybackClock } from "../../../../src/audio/PlaybackClock";
|
import { PlaybackClock } from "../../../../src/audio/PlaybackClock";
|
||||||
import { mkEvent } from "../../../test-utils";
|
import { mkEvent } from "../../../test-utils";
|
||||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
|
||||||
|
|
||||||
jest.mock("../../../../src/utils/local-room", () => ({
|
jest.mock("../../../../src/utils/local-room", () => ({
|
||||||
doMaybeLocalRoomAction: jest.fn(),
|
doMaybeLocalRoomAction: jest.fn(),
|
||||||
|
@ -103,10 +102,6 @@ describe("<VoiceRecordComposerTile/>", () => {
|
||||||
return fn(roomId);
|
return fn(roomId);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
jest.spyOn(SettingsStore, "getValue").mockImplementation(
|
|
||||||
(settingName) => settingName === "feature_intentional_mentions",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("send", () => {
|
describe("send", () => {
|
||||||
|
|
|
@ -15,18 +15,11 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { defer } from "matrix-js-sdk/src/utils";
|
|
||||||
|
|
||||||
import LabsUserSettingsTab from "../../../../../../src/components/views/settings/tabs/user/LabsUserSettingsTab";
|
import LabsUserSettingsTab from "../../../../../../src/components/views/settings/tabs/user/LabsUserSettingsTab";
|
||||||
import SettingsStore from "../../../../../../src/settings/SettingsStore";
|
import SettingsStore from "../../../../../../src/settings/SettingsStore";
|
||||||
import {
|
|
||||||
getMockClientWithEventEmitter,
|
|
||||||
mockClientMethodsServer,
|
|
||||||
mockClientMethodsUser,
|
|
||||||
} from "../../../../../test-utils";
|
|
||||||
import SdkConfig from "../../../../../../src/SdkConfig";
|
import SdkConfig from "../../../../../../src/SdkConfig";
|
||||||
import MatrixClientBackedController from "../../../../../../src/settings/controllers/MatrixClientBackedController";
|
|
||||||
|
|
||||||
describe("<LabsUserSettingsTab />", () => {
|
describe("<LabsUserSettingsTab />", () => {
|
||||||
const sdkConfigSpy = jest.spyOn(SdkConfig, "get");
|
const sdkConfigSpy = jest.spyOn(SdkConfig, "get");
|
||||||
|
@ -36,12 +29,6 @@ describe("<LabsUserSettingsTab />", () => {
|
||||||
};
|
};
|
||||||
const getComponent = () => <LabsUserSettingsTab {...defaultProps} />;
|
const getComponent = () => <LabsUserSettingsTab {...defaultProps} />;
|
||||||
|
|
||||||
const userId = "@alice:server.org";
|
|
||||||
const cli = getMockClientWithEventEmitter({
|
|
||||||
...mockClientMethodsUser(userId),
|
|
||||||
...mockClientMethodsServer(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const settingsValueSpy = jest.spyOn(SettingsStore, "getValue");
|
const settingsValueSpy = jest.spyOn(SettingsStore, "getValue");
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
@ -73,31 +60,4 @@ describe("<LabsUserSettingsTab />", () => {
|
||||||
const labsSections = container.getElementsByClassName("mx_SettingsSubsection");
|
const labsSections = container.getElementsByClassName("mx_SettingsSubsection");
|
||||||
expect(labsSections).toHaveLength(9);
|
expect(labsSections).toHaveLength(9);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("allow setting a labs flag which requires unstable support once support is confirmed", async () => {
|
|
||||||
// enable labs
|
|
||||||
sdkConfigSpy.mockImplementation((configName) => configName === "show_labs_settings");
|
|
||||||
|
|
||||||
const deferred = defer<boolean>();
|
|
||||||
cli.doesServerSupportUnstableFeature.mockImplementation(async (featureName) => {
|
|
||||||
return featureName === "org.matrix.msc3952_intentional_mentions" ? deferred.promise : false;
|
|
||||||
});
|
|
||||||
MatrixClientBackedController.matrixClient = cli;
|
|
||||||
|
|
||||||
const { queryByText } = render(getComponent());
|
|
||||||
|
|
||||||
expect(
|
|
||||||
queryByText("Enable intentional mentions")!
|
|
||||||
.closest(".mx_SettingsFlag")!
|
|
||||||
.querySelector(".mx_AccessibleButton"),
|
|
||||||
).toHaveAttribute("aria-disabled", "true");
|
|
||||||
deferred.resolve(true);
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(
|
|
||||||
queryByText("Enable intentional mentions")!
|
|
||||||
.closest(".mx_SettingsFlag")!
|
|
||||||
.querySelector(".mx_AccessibleButton"),
|
|
||||||
).toHaveAttribute("aria-disabled", "false");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue