diff --git a/package.json b/package.json
index a1d5c3a1f4..ec22f33717 100644
--- a/package.json
+++ b/package.json
@@ -61,7 +61,7 @@
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/analytics-events": "^0.5.0",
- "@matrix-org/matrix-wysiwyg": "^1.4.1",
+ "@matrix-org/matrix-wysiwyg": "^2.0.0",
"@matrix-org/react-sdk-module-api": "^0.0.4",
"@sentry/browser": "^7.0.0",
"@sentry/tracing": "^7.0.0",
diff --git a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss
index b311c3c45b..50f7663598 100644
--- a/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss
+++ b/res/css/views/rooms/wysiwyg_composer/components/_Editor.pcss
@@ -106,19 +106,11 @@ limitations under the License.
in the current composer, there don't appear to be any styles associated with those classes
in this repo */
a[data-mention-type] {
- /* these entries duplicate mx_Pill from _Pill.pcss */
+ /* combine mx_Pill from _Pill.pcss */
padding: $font-1px 0.4em;
line-height: $font-17px;
border-radius: $font-16px;
- vertical-align: text-top;
- /* TODO turning this on hides the cursor from the composer for some
- reason, so comment out for now and assess if it's needed when we add
- the Avatars
- display: inline-flex;
- align-items: center; not required with the above turned off
-
- Potential fix is using display: inline, width: fit-content
- */
+ display: inline;
box-sizing: border-box;
max-width: 100%;
overflow: hidden;
@@ -126,12 +118,40 @@ limitations under the License.
color: $accent-fg-color;
background-color: $pill-bg-color;
- /* combining the overrides from _BasicMessageComposer.pcss */
+ /* ...with the overrides from _BasicMessageComposer.pcss */
user-select: all;
position: relative;
cursor: unset; /* We don't want indicate clickability */
text-overflow: ellipsis;
white-space: nowrap;
+
+ /* avatar pseudo element */
+ &::before {
+ /* After consolidation, all of the styling from _Pill.scss was being overridden,
+ so take what is in _BasicMessageComposer.pcss as the starting point */
+ display: inline-block;
+ content: var(--avatar-letter);
+ background: var(--avatar-background), $background;
+
+ width: $font-16px;
+ min-width: $font-16px; /* ensure the avatar is not compressed */
+ height: $font-16px;
+ line-height: $font-16px;
+ text-align: center;
+
+ /* Get the positioning of the avatar just right for consistency with timeline */
+ margin-inline-start: -0.4rem;
+ margin-inline-end: 0.24rem;
+ vertical-align: 0.12rem;
+
+ background-repeat: no-repeat;
+ background-size: $font-16px;
+ border-radius: $font-16px;
+
+ color: $avatar-initial-color;
+ font-weight: normal;
+ font-size: $font-10-4px;
+ }
}
}
diff --git a/src/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete.tsx b/src/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete.tsx
index fc1b26fa42..3afa409c71 100644
--- a/src/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete.tsx
+++ b/src/components/views/rooms/wysiwyg_composer/components/WysiwygAutocomplete.tsx
@@ -15,13 +15,13 @@ limitations under the License.
*/
import React, { ForwardedRef, forwardRef } from "react";
-import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { FormattingFunctions, MappedSuggestion } from "@matrix-org/matrix-wysiwyg";
import { useRoomContext } from "../../../../../contexts/RoomContext";
import Autocomplete from "../../Autocomplete";
import { ICompletion } from "../../../../../autocomplete/Autocompleter";
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
+import { getMentionDisplayText, getMentionAttributes, buildQuery } from "../utils/autocomplete";
interface WysiwygAutocompleteProps {
/**
@@ -37,55 +37,6 @@ interface WysiwygAutocompleteProps {
handleMention: FormattingFunctions["mention"];
}
-/**
- * Builds the query for the `` component from the rust suggestion. This
- * will change as we implement handling / commands.
- *
- * @param suggestion - represents if the rust model is tracking a potential mention
- * @returns an empty string if we can not generate a query, otherwise a query beginning
- * with @ for a user query, # for a room or space query
- */
-function buildQuery(suggestion: MappedSuggestion | null): string {
- if (!suggestion || !suggestion.keyChar || suggestion.type === "command") {
- // if we have an empty key character, we do not build a query
- // TODO implement the command functionality
- return "";
- }
-
- return `${suggestion.keyChar}${suggestion.text}`;
-}
-
-/**
- * Given a room type mention, determine the text that should be displayed in the mention
- * TODO expand this function to more generally handle outputting the display text from a
- * given completion
- *
- * @param completion - the item selected from the autocomplete, currently treated as a room completion
- * @param client - the MatrixClient is required for us to look up the correct room mention text
- * @returns the text to display in the mention
- */
-function getRoomMentionText(completion: ICompletion, client: MatrixClient): string {
- const roomId = completion.completionId;
- const alias = completion.completion;
-
- let roomForAutocomplete: Room | null | undefined;
-
- // Not quite sure if the logic here makes sense - specifically calling .getRoom with an alias
- // that doesn't start with #, but keeping the logic the same as in PartCreator.roomPill for now
- if (roomId) {
- roomForAutocomplete = client.getRoom(roomId);
- } else if (!alias.startsWith("#")) {
- roomForAutocomplete = client.getRoom(alias);
- } else {
- roomForAutocomplete = client.getRooms().find((r) => {
- return r.getCanonicalAlias() === alias || r.getAltAliases().includes(alias);
- });
- }
-
- // if we haven't managed to find the room, use the alias as a fallback
- return roomForAutocomplete?.name || alias;
-}
-
/**
* Given the current suggestion from the rust model and a handler function, this component
* will display the legacy `` component (as used in the BasicMessageComposer)
@@ -99,22 +50,14 @@ const WysiwygAutocomplete = forwardRef(
const client = useMatrixClientContext();
function handleConfirm(completion: ICompletion): void {
- if (!completion.href || !client) return;
-
- switch (completion.type) {
- case "user":
- handleMention(completion.href, completion.completion);
- break;
- case "room": {
- handleMention(completion.href, getRoomMentionText(completion, client));
- break;
- }
- // TODO implement the command functionality
- // case "command":
- // console.log("/command functionality not yet in place");
- // break;
- default:
- break;
+ // TODO handle all of the completion types
+ // Using this to pick out the ones we can handle during implementation
+ if (client && room && completion.href && (completion.type === "room" || completion.type === "user")) {
+ handleMention(
+ completion.href,
+ getMentionDisplayText(completion, client),
+ getMentionAttributes(completion, client, room),
+ );
}
}
diff --git a/src/components/views/rooms/wysiwyg_composer/utils/autocomplete.ts b/src/components/views/rooms/wysiwyg_composer/utils/autocomplete.ts
new file mode 100644
index 0000000000..d1f066a7bc
--- /dev/null
+++ b/src/components/views/rooms/wysiwyg_composer/utils/autocomplete.ts
@@ -0,0 +1,137 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { Attributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg";
+import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
+
+import { ICompletion } from "../../../../../autocomplete/Autocompleter";
+import * as Avatar from "../../../../../Avatar";
+
+/**
+ * Builds the query for the `` component from the rust suggestion. This
+ * will change as we implement handling / commands.
+ *
+ * @param suggestion - represents if the rust model is tracking a potential mention
+ * @returns an empty string if we can not generate a query, otherwise a query beginning
+ * with @ for a user query, # for a room or space query
+ */
+export function buildQuery(suggestion: MappedSuggestion | null): string {
+ if (!suggestion || !suggestion.keyChar || suggestion.type === "command") {
+ // if we have an empty key character, we do not build a query
+ // TODO implement the command functionality
+ return "";
+ }
+
+ return `${suggestion.keyChar}${suggestion.text}`;
+}
+
+/**
+ * Find the room from the completion by looking it up using the client from the context
+ * we are currently in
+ *
+ * @param completion - the completion from the autocomplete
+ * @param client - the current client we are using
+ * @returns a Room if one is found, null otherwise
+ */
+export function getRoomFromCompletion(completion: ICompletion, client: MatrixClient): Room | null {
+ const roomId = completion.completionId;
+ const aliasFromCompletion = completion.completion;
+
+ let roomToReturn: Room | null | undefined;
+
+ // Not quite sure if the logic here makes sense - specifically calling .getRoom with an alias
+ // that doesn't start with #, but keeping the logic the same as in PartCreator.roomPill for now
+ if (roomId) {
+ roomToReturn = client.getRoom(roomId);
+ } else if (!aliasFromCompletion.startsWith("#")) {
+ roomToReturn = client.getRoom(aliasFromCompletion);
+ } else {
+ roomToReturn = client.getRooms().find((r) => {
+ return r.getCanonicalAlias() === aliasFromCompletion || r.getAltAliases().includes(aliasFromCompletion);
+ });
+ }
+
+ return roomToReturn ?? null;
+}
+
+/**
+ * Given an autocomplete suggestion, determine the text to display in the pill
+ *
+ * @param completion - the item selected from the autocomplete
+ * @param client - the MatrixClient is required for us to look up the correct room mention text
+ * @returns the text to display in the mention
+ */
+export function getMentionDisplayText(completion: ICompletion, client: MatrixClient): string {
+ if (completion.type === "user") {
+ return completion.completion;
+ } else if (completion.type === "room") {
+ // try and get the room and use it's name, if not available, fall back to
+ // completion.completion
+ return getRoomFromCompletion(completion, client)?.name || completion.completion;
+ }
+ return "";
+}
+
+/**
+ * For a given completion, the attributes will change depending on the completion type
+ *
+ * @param completion - the item selected from the autocomplete
+ * @param client - the MatrixClient is required for us to look up the correct room mention text
+ * @returns an object of attributes containing HTMLAnchor attributes or data-* attri
+ */
+export function getMentionAttributes(completion: ICompletion, client: MatrixClient, room: Room): Attributes {
+ // to ensure that we always have something set in the --avatar-letter CSS variable
+ // as otherwise alignment varies depending on whether the content is empty or not
+ const defaultLetterContent = "-";
+
+ if (completion.type === "user") {
+ // logic as used in UserPillPart.setAvatar in parts.ts
+ const mentionedMember = room.getMember(completion.completionId || "");
+
+ if (!mentionedMember) return {};
+
+ const name = mentionedMember.name || mentionedMember.userId;
+ const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(mentionedMember.userId);
+ const avatarUrl = Avatar.avatarUrlForMember(mentionedMember, 16, 16, "crop");
+ let initialLetter = defaultLetterContent;
+ if (avatarUrl === defaultAvatarUrl) {
+ initialLetter = Avatar.getInitialLetter(name) ?? defaultLetterContent;
+ }
+
+ return {
+ "data-mention-type": completion.type,
+ "style": `--avatar-background: url(${avatarUrl}); --avatar-letter: '${initialLetter}'`,
+ };
+ } else if (completion.type === "room") {
+ // logic as used in RoomPillPart.setAvatar in parts.ts
+ const mentionedRoom = getRoomFromCompletion(completion, client);
+ const aliasFromCompletion = completion.completion;
+
+ let initialLetter = defaultLetterContent;
+ let avatarUrl = Avatar.avatarUrlForRoom(mentionedRoom ?? null, 16, 16, "crop");
+ if (!avatarUrl) {
+ initialLetter = Avatar.getInitialLetter(mentionedRoom?.name || aliasFromCompletion) ?? defaultLetterContent;
+ avatarUrl = Avatar.defaultAvatarUrlForString(mentionedRoom?.roomId ?? aliasFromCompletion);
+ }
+
+ return {
+ "data-mention-type": completion.type,
+ "style": `--avatar-background: url(${avatarUrl}); --avatar-letter: '${initialLetter}'`,
+ };
+ }
+
+ return {};
+}
diff --git a/test/components/views/rooms/wysiwyg_composer/utils/autocomplete-test.ts b/test/components/views/rooms/wysiwyg_composer/utils/autocomplete-test.ts
new file mode 100644
index 0000000000..2612f037f2
--- /dev/null
+++ b/test/components/views/rooms/wysiwyg_composer/utils/autocomplete-test.ts
@@ -0,0 +1,224 @@
+/*
+Copyright 2023 The Matrix.org Foundation C.I.C.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+import { mocked } from "jest-mock";
+
+import { ICompletion } from "../../../../../../src/autocomplete/Autocompleter";
+import {
+ buildQuery,
+ getRoomFromCompletion,
+ getMentionDisplayText,
+ getMentionAttributes,
+} from "../../../../../../src/components/views/rooms/wysiwyg_composer/utils/autocomplete";
+import { createTestClient, mkRoom } from "../../../../../test-utils";
+import * as _mockAvatar from "../../../../../../src/Avatar";
+
+const mockClient = createTestClient();
+const mockRoomId = "mockRoomId";
+const mockRoom = mkRoom(mockClient, mockRoomId);
+
+const createMockCompletion = (props: Partial): ICompletion => {
+ return {
+ completion: "mock",
+ range: { beginning: true, start: 0, end: 0 },
+ ...props,
+ };
+};
+
+jest.mock("../../../../../../src/Avatar");
+
+beforeEach(() => jest.clearAllMocks());
+afterAll(() => jest.restoreAllMocks());
+
+describe("buildQuery", () => {
+ it("returns an empty string for a falsy argument", () => {
+ expect(buildQuery(null)).toBe("");
+ });
+
+ it("returns an empty string when keyChar is falsy", () => {
+ const noKeyCharSuggestion = { keyChar: "" as const, text: "test", type: "unknown" as const };
+ expect(buildQuery(noKeyCharSuggestion)).toBe("");
+ });
+
+ it("returns an empty string when suggestion is a command", () => {
+ // TODO alter this test when commands are implemented
+ const commandSuggestion = { keyChar: "/" as const, text: "slash", type: "command" as const };
+ expect(buildQuery(commandSuggestion)).toBe("");
+ });
+
+ it("combines the keyChar and text of the suggestion in the query", () => {
+ const handledSuggestion = { keyChar: "@" as const, text: "alice", type: "mention" as const };
+ expect(buildQuery(handledSuggestion)).toBe("@alice");
+ });
+});
+
+describe("getRoomFromCompletion", () => {
+ const createMockRoomCompletion = (props: Partial): ICompletion => {
+ return createMockCompletion({ ...props, type: "room" });
+ };
+
+ it("calls getRoom with completionId if present in the completion", () => {
+ const testId = "arbitraryId";
+ const completionWithId = createMockRoomCompletion({ completionId: testId });
+
+ getRoomFromCompletion(completionWithId, mockClient);
+
+ expect(mockClient.getRoom).toHaveBeenCalledWith(testId);
+ });
+
+ it("calls getRoom with completion if present and correct format", () => {
+ const testCompletion = "arbitraryCompletion";
+ const completionWithId = createMockRoomCompletion({ completionId: testCompletion });
+
+ getRoomFromCompletion(completionWithId, mockClient);
+
+ expect(mockClient.getRoom).toHaveBeenCalledWith(testCompletion);
+ });
+
+ it("calls getRooms if no completionId is present and completion starts with #", () => {
+ const completionWithId = createMockRoomCompletion({ completion: "#hash" });
+
+ const result = getRoomFromCompletion(completionWithId, mockClient);
+
+ expect(mockClient.getRoom).not.toHaveBeenCalled();
+ expect(mockClient.getRooms).toHaveBeenCalled();
+
+ // in this case, because the mock client returns an empty array of rooms
+ // from the call to get rooms, we'd expect the result to be null
+ expect(result).toBe(null);
+ });
+});
+
+describe("getMentionDisplayText", () => {
+ it("returns an empty string if we are not handling a user or a room type", () => {
+ const nonHandledCompletionTypes = ["at-room", "community", "command"] as const;
+ const nonHandledCompletions = nonHandledCompletionTypes.map((type) => createMockCompletion({ type }));
+
+ nonHandledCompletions.forEach((completion) => {
+ expect(getMentionDisplayText(completion, mockClient)).toBe("");
+ });
+ });
+
+ it("returns the completion if we are handling a user", () => {
+ const testCompletion = "display this";
+ const userCompletion = createMockCompletion({ type: "user", completion: testCompletion });
+
+ expect(getMentionDisplayText(userCompletion, mockClient)).toBe(testCompletion);
+ });
+
+ it("returns the room name when the room has a valid completionId", () => {
+ const testCompletionId = "testId";
+ const userCompletion = createMockCompletion({ type: "room", completionId: testCompletionId });
+
+ // as this uses the mockClient, the name will be the mock room name returned from there
+ expect(getMentionDisplayText(userCompletion, mockClient)).toBe(mockClient.getRoom("")?.name);
+ });
+
+ it("falls back to the completion for a room if completion starts with #", () => {
+ const testCompletion = "#hash";
+ const userCompletion = createMockCompletion({ type: "room", completion: testCompletion });
+
+ // as this uses the mockClient, the name will be the mock room name returned from there
+ expect(getMentionDisplayText(userCompletion, mockClient)).toBe(testCompletion);
+ });
+});
+
+describe("getMentionAttributes", () => {
+ // TODO handle all completion types
+ it("returns an empty object for completion types other than room or user", () => {
+ const nonHandledCompletionTypes = ["at-room", "community", "command"] as const;
+ const nonHandledCompletions = nonHandledCompletionTypes.map((type) => createMockCompletion({ type }));
+
+ nonHandledCompletions.forEach((completion) => {
+ expect(getMentionAttributes(completion, mockClient, mockRoom)).toEqual({});
+ });
+ });
+
+ const testAvatarUrlForString = "www.stringUrl.com";
+ const testAvatarUrlForMember = "www.memberUrl.com";
+ const testAvatarUrlForRoom = "www.roomUrl.com";
+ const testInitialLetter = "z";
+
+ const mockAvatar = mocked(_mockAvatar);
+ mockAvatar.defaultAvatarUrlForString.mockReturnValue(testAvatarUrlForString);
+ mockAvatar.avatarUrlForMember.mockReturnValue(testAvatarUrlForMember);
+ mockAvatar.avatarUrlForRoom.mockReturnValue(testAvatarUrlForRoom);
+ mockAvatar.getInitialLetter.mockReturnValue(testInitialLetter);
+
+ describe("user mentions", () => {
+ it("returns an empty object when no member can be found", () => {
+ const userCompletion = createMockCompletion({ type: "user" });
+
+ // mock not being able to find a member
+ mockRoom.getMember.mockImplementationOnce(() => null);
+
+ const result = getMentionAttributes(userCompletion, mockClient, mockRoom);
+ expect(result).toEqual({});
+ });
+
+ it("returns expected attributes when avatar url is not default", () => {
+ const userCompletion = createMockCompletion({ type: "user" });
+
+ const result = getMentionAttributes(userCompletion, mockClient, mockRoom);
+
+ expect(result).toEqual({
+ "data-mention-type": "user",
+ "style": `--avatar-background: url(${testAvatarUrlForMember}); --avatar-letter: '-'`,
+ });
+ });
+
+ it("returns expected style attributes when avatar url matches default", () => {
+ const userCompletion = createMockCompletion({ type: "user" });
+
+ // mock a single implementation of avatarUrlForMember to make it match the default
+ mockAvatar.avatarUrlForMember.mockReturnValueOnce(testAvatarUrlForString);
+
+ const result = getMentionAttributes(userCompletion, mockClient, mockRoom);
+
+ expect(result).toEqual({
+ "data-mention-type": "user",
+ "style": `--avatar-background: url(${testAvatarUrlForString}); --avatar-letter: '${testInitialLetter}'`,
+ });
+ });
+ });
+
+ describe("room mentions", () => {
+ it("returns expected attributes when avatar url for room is truthy", () => {
+ const userCompletion = createMockCompletion({ type: "room" });
+
+ const result = getMentionAttributes(userCompletion, mockClient, mockRoom);
+
+ expect(result).toEqual({
+ "data-mention-type": "room",
+ "style": `--avatar-background: url(${testAvatarUrlForRoom}); --avatar-letter: '-'`,
+ });
+ });
+
+ it("returns expected style attributes when avatar url for room is falsy", () => {
+ const userCompletion = createMockCompletion({ type: "room" });
+
+ // mock a single implementation of avatarUrlForRoom to make it falsy
+ mockAvatar.avatarUrlForRoom.mockReturnValueOnce(null);
+
+ const result = getMentionAttributes(userCompletion, mockClient, mockRoom);
+
+ expect(result).toEqual({
+ "data-mention-type": "room",
+ "style": `--avatar-background: url(${testAvatarUrlForString}); --avatar-letter: '${testInitialLetter}'`,
+ });
+ });
+ });
+});
diff --git a/yarn.lock b/yarn.lock
index bcfed948ad..31e8f3c2f3 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1704,10 +1704,10 @@
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.5.tgz#60ede2c43b9d808ba8cf46085a3b347b290d9658"
integrity sha512-2KjAgWNGfuGLNjJwsrs6gGX157vmcTfNrA4u249utgnMPbJl7QwuUqh1bGxQ0PpK06yvZjgPlkna0lTbuwtuQw==
-"@matrix-org/matrix-wysiwyg@^1.4.1":
- version "1.4.1"
- resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-1.4.1.tgz#80c392f036dc4d6ef08a91c4964a68682e977079"
- integrity sha512-B8sxY3pE2XyRyQ1g7cx0YjGaDZ1A0Uh5XxS/lNdxQ/0ctRJj6IBy7KtiUjxDRdA15ioZnf6aoJBRkBSr02qhaw==
+"@matrix-org/matrix-wysiwyg@^2.0.0":
+ version "2.0.0"
+ resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-2.0.0.tgz#913eb5faa5d43c7a4ee9bda68de1aa1dcc49a11d"
+ integrity sha512-xRYFwsf6Jx7KTCpwU91mVhPA8q/c+oOVyK98NnexyK/IcQS7BMFAns5GZX9b6ZEy38u30KoxeN6mxvxi+ysQgg==
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz":
version "3.2.14"