diff --git a/package.json b/package.json index 86d43d9aa5..4a57cd3417 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "@fontsource/inter": "^5", "@formatjs/intl-segmenter": "^11.5.7", "@matrix-org/analytics-events": "^0.29.2", - "@matrix-org/emojibase-bindings": "^1.4.0", + "@matrix-org/emojibase-bindings": "^1.3.4", "@matrix-org/react-sdk-module-api": "^2.4.0", "@matrix-org/spec": "^1.7.0", "@sentry/browser": "^9.0.0", @@ -109,7 +109,7 @@ "diff-dom": "^5.0.0", "diff-match-patch": "^1.0.5", "domutils": "^3.2.2", - "emojibase-regex": "16.0.0", + "emojibase-regex": "15.3.2", "escape-html": "^1.0.3", "file-saver": "^2.0.5", "filesize": "10.1.6", diff --git a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 index 9b7e0c45e8..5bfc425d66 100644 Binary files a/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 and b/res/fonts/Twemoji_Mozilla/TwemojiMozilla-colr.woff2 differ diff --git a/src/Avatar.ts b/src/Avatar.ts index abddfd87f1..921332c250 100644 --- a/src/Avatar.ts +++ b/src/Avatar.ts @@ -147,11 +147,12 @@ export function avatarUrlForRoom( width?: number, height?: number, resizeMethod?: ResizeMethod, + avatarMxcOverride?: string, ): string | null { if (!room) return null; // null-guard - - if (room.getMxcAvatarUrl()) { - const media = mediaFromMxc(room.getMxcAvatarUrl() ?? undefined); + const mxc = avatarMxcOverride ?? room.getMxcAvatarUrl(); + if (mxc) { + const media = mediaFromMxc(mxc); if (width !== undefined && height !== undefined) { return media.getThumbnailOfSourceHttp(width, height, resizeMethod); } diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index 137ac5de7b..6490378731 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -7,20 +7,20 @@ Please see LICENSE files in the repository root for full details. */ import React, { useCallback, useMemo, type ComponentProps } from "react"; -import { type Room, RoomType, KnownMembership } from "matrix-js-sdk/src/matrix"; +import { type Room, RoomType, KnownMembership, EventType } from "matrix-js-sdk/src/matrix"; +import { type RoomAvatarEventContent } from "matrix-js-sdk/src/types"; import BaseAvatar from "./BaseAvatar"; import ImageView from "../elements/ImageView"; import Modal from "../../../Modal"; import * as Avatar from "../../../Avatar"; -import DMRoomMap from "../../../utils/DMRoomMap"; import { mediaFromMxc } from "../../../customisations/Media"; import { type IOOBData } from "../../../stores/ThreepidInviteStore"; -import { LocalRoom } from "../../../models/LocalRoom"; import { filterBoolean } from "../../../utils/arrays"; -import { MediaPreviewValue } from "../../../@types/media_preview"; -import { useRoomAvatar } from "../../../hooks/room/useRoomAvatar"; import { useSettingValue } from "../../../hooks/useSettings"; +import { useRoomState } from "../../../hooks/useRoomState"; +import { useRoomIdName } from "../../../hooks/room/useRoomIdName"; +import { MediaPreviewValue } from "../../../@types/media_preview"; interface IProps extends Omit, "name" | "idName" | "url" | "onClick" | "size"> { // Room may be left unset here, but if it is, @@ -36,37 +36,16 @@ interface IProps extends Omit, "name" | "idNam onClick?(): void; } -export function idNameForRoom(room: Room): string { - const dmMapUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); - // If the room is a DM, we use the other user's ID for the color hash - // in order to match the room avatar with their avatar - if (dmMapUserId) return dmMapUserId; - - if (room instanceof LocalRoom && room.targets.length === 1) { - return room.targets[0].userId; - } - - return room.roomId; -} - -const RoomAvatar: React.FC = ({ room, viewAvatarOnClick, onClick, oobData, ...otherProps }) => { - const size = otherProps.size ?? "36px"; - +const RoomAvatar: React.FC = ({ room, viewAvatarOnClick, onClick, oobData, size = "36px", ...otherProps }) => { const roomName = room?.name ?? oobData?.name ?? "?"; - const roomAvatarMxc = useRoomAvatar(room); - const roomIdName = useMemo(() => { - if (room) { - return idNameForRoom(room); - } else { - return oobData?.roomId; - } - }, [oobData, room]); + const avatarEvent = useRoomState(room, (state) => state.getStateEvents(EventType.RoomAvatar, "")); + const roomIdName = useRoomIdName(room, oobData); - const mediaPreviewEnabled = + const showAvatarsOnInvites = useSettingValue("mediaPreviewConfig", room?.roomId).invite_avatars === MediaPreviewValue.On; const onRoomAvatarClick = useCallback(() => { - const avatarUrl = Avatar.avatarUrlForRoom(room ?? null, undefined, undefined, undefined); + const avatarUrl = Avatar.avatarUrlForRoom(room ?? null); if (!avatarUrl) return; const params = { src: avatarUrl, @@ -77,15 +56,14 @@ const RoomAvatar: React.FC = ({ room, viewAvatarOnClick, onClick, oobDat }, [room]); const urls = useMemo(() => { - // Apparently parseInt ignores suffixes. - const sizeInt = parseInt(size, 10); const myMembership = room?.getMyMembership(); - if (myMembership === KnownMembership.Invite || !myMembership) { - if (!mediaPreviewEnabled) { - // The user has opted out of showing avatars, so return no urls here. - return []; - } + if (!showAvatarsOnInvites && (myMembership === KnownMembership.Invite || !myMembership)) { + // The user has opted out of showing avatars, so return no urls here. + return []; } + + // parseInt ignores suffixes. + const sizeInt = parseInt(size, 10); let oobAvatar: string | null = null; if (oobData?.avatarUrl) { oobAvatar = mediaFromMxc(oobData?.avatarUrl).getThumbnailOfSourceHttp(sizeInt, sizeInt, "crop"); @@ -93,9 +71,15 @@ const RoomAvatar: React.FC = ({ room, viewAvatarOnClick, onClick, oobDat return filterBoolean([ oobAvatar, // highest priority - roomAvatarMxc && Avatar.avatarUrlForRoom(room ?? null, sizeInt, sizeInt, "crop"), + Avatar.avatarUrlForRoom( + room ?? null, + sizeInt, + sizeInt, + "crop", + avatarEvent?.getContent().url, + ), ]); - }, [mediaPreviewEnabled, room, size, roomAvatarMxc, oobData]); + }, [showAvatarsOnInvites, room, size, avatarEvent, oobData]); return ( { - const [dmMember, setDmMember] = useState(getDmMember(room)); +export const useDmMember = (room?: Room): RoomMember | null => { + const [dmMember, setDmMember] = useState(room ? getDmMember(room) : null); const updateDmMember = (): void => { - setDmMember(getDmMember(room)); + setDmMember(room ? getDmMember(room) : null); }; - useEventEmitter(room.currentState, RoomStateEvent.Members, updateDmMember); - useEventEmitter(room.client, ClientEvent.AccountData, updateDmMember); + useEventEmitter(room?.currentState, RoomStateEvent.Members, updateDmMember); + useEventEmitter(room?.client, ClientEvent.AccountData, updateDmMember); useEffect(updateDmMember, [room]); return dmMember; diff --git a/src/components/views/messages/RoomAvatarEvent.tsx b/src/components/views/messages/RoomAvatarEvent.tsx index 7c155e59d6..c80c80c836 100644 --- a/src/components/views/messages/RoomAvatarEvent.tsx +++ b/src/components/views/messages/RoomAvatarEvent.tsx @@ -70,7 +70,7 @@ export default class RoomAvatarEvent extends React.Component { className="mx_RoomAvatarEvent_avatar" onClick={this.onAvatarClick} > - + ), }, diff --git a/src/components/views/room_settings/RoomProfileSettings.tsx b/src/components/views/room_settings/RoomProfileSettings.tsx index 26cd16e873..e7674a109a 100644 --- a/src/components/views/room_settings/RoomProfileSettings.tsx +++ b/src/components/views/room_settings/RoomProfileSettings.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. import React, { createRef } from "react"; import classNames from "classnames"; -import { ContentHelpers, EventType } from "matrix-js-sdk/src/matrix"; +import { ContentHelpers, EventType, type Room } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../languageHandler"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; @@ -15,7 +15,8 @@ import Field from "../elements/Field"; import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; import AvatarSetting from "../settings/AvatarSetting"; import { htmlSerializeFromMdIfNeeded } from "../../../editor/serialize"; -import { idNameForRoom } from "../avatars/RoomAvatar"; +import DMRoomMap from "../../../utils/DMRoomMap"; +import { LocalRoom } from "../../../models/LocalRoom"; interface IProps { roomId: string; @@ -36,6 +37,19 @@ interface IState { canSetAvatar: boolean; } +function idNameForRoom(room: Room): string { + const dmMapUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); + // If the room is a DM, we use the other user's ID for the color hash + // in order to match the room avatar with their avatar + if (dmMapUserId) return dmMapUserId; + + if (room instanceof LocalRoom && room.targets.length === 1) { + return room.targets[0].userId; + } + + return room.roomId; +} + // TODO: Merge with ProfileSettings? export default class RoomProfileSettings extends React.Component { private avatarUpload = createRef(); diff --git a/src/components/views/rooms/MemberList/MemberListHeaderView.tsx b/src/components/views/rooms/MemberList/MemberListHeaderView.tsx index 3c6fad8aa1..88fc07881c 100644 --- a/src/components/views/rooms/MemberList/MemberListHeaderView.tsx +++ b/src/components/views/rooms/MemberList/MemberListHeaderView.tsx @@ -52,6 +52,7 @@ const InviteButton: React.FC = ({ vm }) => { Icon={InviteIcon} disabled={disabled} aria-label={_t("action|invite")} + type="button" /> ); @@ -67,6 +68,7 @@ const InviteButton: React.FC = ({ vm }) => { className="mx_MemberListHeaderView_invite_large" disabled={!vm.canInvite} onClick={vm.onInviteButtonClick} + type="button" > {_t("action|invite")} diff --git a/src/hooks/room/useRoomIdName.ts b/src/hooks/room/useRoomIdName.ts new file mode 100644 index 0000000000..c7930029e2 --- /dev/null +++ b/src/hooks/room/useRoomIdName.ts @@ -0,0 +1,32 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { type Room } from "matrix-js-sdk/src/matrix"; + +import { useDmMember } from "../../components/views/avatars/WithPresenceIndicator.tsx"; +import { LocalRoom } from "../../models/LocalRoom.ts"; + +/** + * Determine a stable ID for generating hash colours. If the room + * is a DM (or local room), then the other user's ID will be used. + * @param oobData - out-of-band information about the room + * @returns An ID string, or undefined if the room and oobData are undefined. + */ +export function useRoomIdName(room?: Room, oobData?: { roomId?: string }): string | undefined { + const dmMember = useDmMember(room); + if (dmMember) { + // If the room is a DM, we use the other user's ID for the color hash + // in order to match the room avatar with their avatar + return dmMember.userId; + } else if (room instanceof LocalRoom && room.targets.length === 1) { + return room.targets[0].userId; + } else if (room) { + return room.roomId; + } else { + return oobData?.roomId; + } +} diff --git a/test/unit-tests/components/views/avatars/DecoratedRoomAvatar-test.tsx b/test/unit-tests/components/views/avatars/DecoratedRoomAvatar-test.tsx index f4a386f9cb..e41f068d21 100644 --- a/test/unit-tests/components/views/avatars/DecoratedRoomAvatar-test.tsx +++ b/test/unit-tests/components/views/avatars/DecoratedRoomAvatar-test.tsx @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import { render, waitFor } from "jest-matrix-react"; import { mocked } from "jest-mock"; -import { JoinRule, type MatrixClient, PendingEventOrdering, Room } from "matrix-js-sdk/src/matrix"; +import { JoinRule, type MatrixClient, PendingEventOrdering, Room, RoomMember } from "matrix-js-sdk/src/matrix"; import React from "react"; import userEvent from "@testing-library/user-event"; @@ -79,6 +79,7 @@ describe("DecoratedRoomAvatar", () => { } as unknown as DMRoomMap; jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); jest.spyOn(DecoratedRoomAvatar.prototype as any, "getPresenceIcon").mockImplementation(() => "ONLINE"); + jest.spyOn(room, "getMember").mockReturnValue(new RoomMember(room.roomId, DM_USER_ID)); const { container, asFragment } = renderComponent(); diff --git a/test/unit-tests/components/views/avatars/RoomAvatar-test.tsx b/test/unit-tests/components/views/avatars/RoomAvatar-test.tsx index 9ef692e7f8..1c8db39040 100644 --- a/test/unit-tests/components/views/avatars/RoomAvatar-test.tsx +++ b/test/unit-tests/components/views/avatars/RoomAvatar-test.tsx @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import { render } from "jest-matrix-react"; -import { type MatrixClient, Room } from "matrix-js-sdk/src/matrix"; +import { EventType, type MatrixClient, MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix"; import { mocked } from "jest-mock"; import RoomAvatar from "../../../../../src/components/views/avatars/RoomAvatar"; @@ -61,6 +61,7 @@ describe("RoomAvatar", () => { it("should render as expected for a DM room", () => { const userId = "@dm_user@example.com"; const room = new Room("!room:example.com", client, client.getSafeUserId()); + room.getMember = jest.fn().mockImplementation(() => new RoomMember(room.roomId, userId)); room.name = "DM room"; mocked(DMRoomMap.shared().getUserIdForRoomId).mockReturnValue(userId); expect(render().container).toMatchSnapshot(); @@ -78,6 +79,17 @@ describe("RoomAvatar", () => { jest.spyOn(room, "getMxcAvatarUrl").mockImplementation(() => "mxc://example.com/foobar"); room.name = "test room"; room.updateMyMembership("invite"); + room.currentState.setStateEvents([ + new MatrixEvent({ + sender: "@sender:server", + room_id: room.roomId, + type: EventType.RoomAvatar, + state_key: "", + content: { + url: "mxc://example.com/foobar", + }, + }), + ]); expect(render().container).toMatchSnapshot(); }); it("should not render an invite avatar if the user has disabled it", () => { diff --git a/yarn.lock b/yarn.lock index 03a1110c32..e39506bca3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2158,13 +2158,13 @@ resolved "https://registry.yarnpkg.com/@matrix-org/analytics-events/-/analytics-events-0.29.2.tgz#20d9877f11d5e411f1610f396f9e490673d6da50" integrity sha512-kpCdf6DBxgE7MbBbYr7FvahrktHHtiph3QN10I6nBAAPQ+hmR3aZHBECxjxLQ9RxvtBF9nlKK4bgy2YrNp6j3A== -"@matrix-org/emojibase-bindings@^1.4.0": - version "1.4.0" - resolved "https://registry.yarnpkg.com/@matrix-org/emojibase-bindings/-/emojibase-bindings-1.4.0.tgz#ad1f917b03cd1fcf049bc3de809beb6cbae78009" - integrity sha512-5PsY183hHK04I8uBCIoyVvZefu/VJYB5YhoM7DAHn0WQtedn70ZCES9iUxcyMRFGzfwiiqd+ArsK8VwLN5JEVA== +"@matrix-org/emojibase-bindings@^1.3.4": + version "1.3.4" + resolved "https://registry.yarnpkg.com/@matrix-org/emojibase-bindings/-/emojibase-bindings-1.3.4.tgz#b0dad8e8b8bbe433e419b59e38f933bcdaf9c271" + integrity sha512-+nhBg0dxjy3U4/Tn6WIsnzqiqazc0pfStc2dkSBxDnc4xnimDB6vcIad53fUIsl7SeT50ake0hhnBJs0ZDDk6Q== dependencies: - emojibase "^16.0.0" - emojibase-data "^16.0.3" + emojibase "^15.3.1" + emojibase-data "^15.3.1" "@matrix-org/matrix-sdk-crypto-wasm@^14.0.1": version "14.0.1" @@ -3738,7 +3738,7 @@ classnames "^2.5.1" vaul "^1.0.0" -"@vector-im/matrix-wysiwyg-wasm@link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm": +"@vector-im/matrix-wysiwyg-wasm@link:../../../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm": version "0.0.0" uid "" @@ -3747,7 +3747,7 @@ resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.38.3.tgz#cc54d8b3e9472bcd8e622126ba364ee31952cd8a" integrity sha512-fqo8P55Vc/t0vxpFar9RDJN5gKEjJmzrLo+O4piDbFda6VrRoqrWAtiu0Au0g6B4hRDPKIuFupk8v9Ja7q8Hvg== dependencies: - "@vector-im/matrix-wysiwyg-wasm" "link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm" + "@vector-im/matrix-wysiwyg-wasm" "link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.38.3-cc54d8b3e9472bcd8e622126ba364ee31952cd8a-integrity/node_modules/bindings/wysiwyg-wasm" "@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1": version "1.14.1" @@ -5941,20 +5941,20 @@ emoji-regex@^9.2.2: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== -emojibase-data@^16.0.3: - version "16.0.3" - resolved "https://registry.yarnpkg.com/emojibase-data/-/emojibase-data-16.0.3.tgz#564ddfe11a2fdcba24975335f857dc85ee895027" - integrity sha512-MopInVCDZeXvqBMPJxnvYUyKw9ImJZqIDr2sABo6acVSPev5IDYX+mf+0tsu96JJyc3INNvgIf06Eso7bdTX2Q== +emojibase-data@^15.3.1: + version "15.3.2" + resolved "https://registry.yarnpkg.com/emojibase-data/-/emojibase-data-15.3.2.tgz#2742246bfe14f16a7829b42ca156dec09934cf85" + integrity sha512-TpDyTDDTdqWIJixV5sTA6OQ0P0JfIIeK2tFRR3q56G9LK65ylAZ7z3KyBXokpvTTJ+mLUXQXbLNyVkjvnTLE+A== -emojibase-regex@16.0.0: - version "16.0.0" - resolved "https://registry.yarnpkg.com/emojibase-regex/-/emojibase-regex-16.0.0.tgz#e648d8789dc22c6adc9a10b1af47135559f65a88" - integrity sha512-ZMp31BkzBWNW+T73of6NURL6nXQa5GkfKneOkr3cEwBDVllbW/2nuva7NO0J3RjaQ07+SZQNgPTGZ4JlIhmM2Q== +emojibase-regex@15.3.2: + version "15.3.2" + resolved "https://registry.yarnpkg.com/emojibase-regex/-/emojibase-regex-15.3.2.tgz#5175231715b86d4b437754527288844a6c29318f" + integrity sha512-ue6BVeb2qu33l97MkxcOoyMJlg6Tug3eTv2z1at+M9TjvlWKvdmAPvZIDG1JbT2RH3FSyJNLucO5K5H/yxT03w== -emojibase@^16.0.0: - version "16.0.0" - resolved "https://registry.yarnpkg.com/emojibase/-/emojibase-16.0.0.tgz#9da603b7d740645d0a5d21c6dcfb97c53d6f96c7" - integrity sha512-Nw2m7JLIO4Ou2X/yZPRNscHQXVbbr6SErjkJ7EooG7MbR3yDZszCv9KTizsXFc7yZl0n3WF+qUKIC/Lw6H9xaQ== +emojibase@^15.3.1: + version "15.3.1" + resolved "https://registry.yarnpkg.com/emojibase/-/emojibase-15.3.1.tgz#7f6ff5482486f23e59a457de64e974bd35f3c9a3" + integrity sha512-GNsjHnG2J3Ktg684Fs/vZR/6XpOSkZPMAv85EHrr6br2RN2cJNwdS4am/3YSK3y+/gOv2kmoK3GGdahXdMxg2g== emojis-list@^3.0.0: version "3.0.0"