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"