Compare commits
20 Commits
langleyd/r
...
actions/pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de5b61ed9b | ||
|
|
af9bde5137 | ||
|
|
ee8c1ffef4 | ||
|
|
fa1043426a | ||
|
|
18a7250cf9 | ||
|
|
7e5f96c85d | ||
|
|
a8e0b54d8a | ||
|
|
302e3e153e | ||
|
|
7642054b74 | ||
|
|
f6955124ac | ||
|
|
9207f25dc3 | ||
|
|
f476da8bec | ||
|
|
34e08af274 | ||
|
|
6c4bd0c8b1 | ||
|
|
88e06cdc55 | ||
|
|
8e3830acee | ||
|
|
6fc3dd4628 | ||
|
|
c313c720de | ||
|
|
23a42e0d54 | ||
|
|
bb23a98bc6 |
2
.github/workflows/docker.yaml
vendored
2
.github/workflows/docker.yaml
vendored
@@ -132,7 +132,7 @@ jobs:
|
||||
cosign sign --yes ${images}
|
||||
|
||||
- name: Update repo description
|
||||
uses: peter-evans/dockerhub-description@0505d8b04853a30189aee66f5bb7fd1511bbac71 # v4
|
||||
uses: peter-evans/dockerhub-description@432a30c9e07499fd01da9f8a49f0faf9e0ca5b77 # v4
|
||||
if: github.event_name != 'pull_request'
|
||||
continue-on-error: true
|
||||
with:
|
||||
|
||||
6
.github/workflows/release_prepare.yml
vendored
6
.github/workflows/release_prepare.yml
vendored
@@ -100,7 +100,7 @@ jobs:
|
||||
repo: matrix-org/matrix-js-sdk
|
||||
repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
wait-interval: 10
|
||||
check-name: draft
|
||||
check-name: "draft / draft"
|
||||
allowed-conclusions: success
|
||||
|
||||
- name: Wait for element-web draft
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
repo: element-hq/element-web
|
||||
repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
wait-interval: 10
|
||||
check-name: draft
|
||||
check-name: "draft / draft"
|
||||
allowed-conclusions: success
|
||||
|
||||
- name: Wait for element-desktop draft
|
||||
@@ -122,5 +122,5 @@ jobs:
|
||||
repo: element-hq/element-desktop
|
||||
repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
wait-interval: 10
|
||||
check-name: draft
|
||||
check-name: "draft / draft"
|
||||
allowed-conclusions: success
|
||||
|
||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -104,7 +104,7 @@ jobs:
|
||||
|
||||
- name: Skip SonarCloud in merge queue
|
||||
if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true'
|
||||
uses: guibranco/github-status-action-v2@9b1d102b3c32583174557f58c53e3b09d43d1b1d
|
||||
uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# syntax=docker.io/docker/dockerfile:1.14-labs
|
||||
# syntax=docker.io/docker/dockerfile:1.15-labs
|
||||
|
||||
# Builder
|
||||
FROM --platform=$BUILDPLATFORM node:22-bullseye AS builder
|
||||
|
||||
12
package.json
12
package.json
@@ -75,7 +75,7 @@
|
||||
"oidc-client-ts": "3.2.0",
|
||||
"jwt-decode": "4.0.0",
|
||||
"caniuse-lite": "1.0.30001707",
|
||||
"testcontainers": "10.23.0",
|
||||
"testcontainers": "10.24.2",
|
||||
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
|
||||
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
|
||||
},
|
||||
@@ -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",
|
||||
@@ -186,7 +186,7 @@
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
|
||||
"@sentry/webpack-plugin": "^3.0.0",
|
||||
"@stylistic/eslint-plugin": "^3.0.0",
|
||||
"@stylistic/eslint-plugin": "^4.0.0",
|
||||
"@svgr/webpack": "^8.0.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.4.8",
|
||||
@@ -246,7 +246,7 @@
|
||||
"eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-unicorn": "^56.0.0",
|
||||
"express": "^4.18.2",
|
||||
"express": "^5.0.0",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"fetch-mock": "9.11.0",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
@@ -292,7 +292,7 @@
|
||||
"terser-webpack-plugin": "^5.3.9",
|
||||
"testcontainers": "^10.20.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "5.8.2",
|
||||
"typescript": "5.8.3",
|
||||
"util": "^0.12.5",
|
||||
"web-streams-polyfill": "^4.0.0",
|
||||
"webpack": "^5.89.0",
|
||||
|
||||
@@ -19,7 +19,14 @@ test.describe("Invites", () => {
|
||||
const roomId = await bot.createRoom({ is_direct: true });
|
||||
await bot.inviteUser(roomId, user.userId);
|
||||
await app.viewRoomByName("Bob");
|
||||
await expect(page.locator(".mx_RoomView")).toMatchScreenshot("Invites_room_view.png");
|
||||
await expect(page.locator(".mx_RoomView")).toMatchScreenshot("Invites_room_view.png", {
|
||||
// Hide the mxid, which is not stable.
|
||||
css: `
|
||||
.mx_RoomPreviewBar_inviter_mxid {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
});
|
||||
});
|
||||
|
||||
test("should be able to decline an invite", async ({ page, homeserver, user, bot, app }) => {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 15 KiB |
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
|
||||
|
||||
const TAG = "develop@sha256:66955f34a593cfc3b6e77b8d5510c60c6094f5bade8a17d2feaefbb8662ccf09";
|
||||
const TAG = "develop@sha256:be7883f4021e24d71bdb8fe744ae6758a5fd15b5491dad97236b29283e3e6705";
|
||||
|
||||
/**
|
||||
* SynapseContainer which freezes the docker digest to stabilise tests,
|
||||
|
||||
Binary file not shown.
@@ -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);
|
||||
}
|
||||
|
||||
@@ -5,32 +5,11 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
EventType,
|
||||
JoinRule,
|
||||
type MatrixEvent,
|
||||
type Room,
|
||||
RoomEvent,
|
||||
type User,
|
||||
UserEvent,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { EventType, JoinRule, type MatrixEvent, type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { getJoinedNonFunctionalMembers } from "../../../utils/room/getJoinedNonFunctionalMembers";
|
||||
import { BUSY_PRESENCE_NAME } from "../../views/rooms/PresenceLabel";
|
||||
import { isPresenceEnabled } from "../../../utils/presence";
|
||||
|
||||
/**
|
||||
* The presence of a user in a DM room.
|
||||
* - "online": The user is online.
|
||||
* - "offline": The user is offline.
|
||||
* - "busy": The user is busy.
|
||||
* - "unavailable": the presence is unavailable.
|
||||
* - null: the user is not in a DM room or presence is not enabled.
|
||||
*/
|
||||
export type Presence = "online" | "offline" | "busy" | "unavailable" | null;
|
||||
import { useDmMember, usePresence, type Presence } from "../../views/avatars/WithPresenceIndicator";
|
||||
|
||||
export interface RoomAvatarViewState {
|
||||
/**
|
||||
@@ -50,7 +29,7 @@ export interface RoomAvatarViewState {
|
||||
* The presence of the user in the DM room.
|
||||
* If null, the user is not in a DM room or presence is not enabled.
|
||||
*/
|
||||
presence: Presence;
|
||||
presence: Presence | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +38,8 @@ export interface RoomAvatarViewState {
|
||||
*/
|
||||
export function useRoomAvatarViewModel(room: Room): RoomAvatarViewState {
|
||||
const isVideoRoom = room.isElementVideoRoom() || room.isCallRoom();
|
||||
const presence = useDMPresence(room);
|
||||
const roomMember = useDmMember(room);
|
||||
const presence = usePresence(room, roomMember);
|
||||
const isPublic = useIsPublic(room);
|
||||
|
||||
const hasDecoration = isPublic || isVideoRoom || presence !== null;
|
||||
@@ -97,48 +77,3 @@ function useIsPublic(room: Room): boolean {
|
||||
function isRoomPublic(room: Room): boolean {
|
||||
return room.getJoinRule() === JoinRule.Public;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook listening to the presence of the DM user.
|
||||
* @param room
|
||||
*/
|
||||
function useDMPresence(room: Room): Presence {
|
||||
const dmUser = getDMUser(room);
|
||||
const [presence, setPresence] = useState<Presence>(getPresence(dmUser));
|
||||
useTypedEventEmitter(dmUser, UserEvent.Presence, () => setPresence(getPresence(dmUser)));
|
||||
useTypedEventEmitter(dmUser, UserEvent.CurrentlyActive, () => setPresence(getPresence(dmUser)));
|
||||
|
||||
return presence;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the DM user of the room.
|
||||
* Return undefined if the room is not a DM room, if we can't find the user or if the presence is not enabled.
|
||||
* @param room
|
||||
* @returns found user
|
||||
*/
|
||||
function getDMUser(room: Room): User | undefined {
|
||||
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
|
||||
if (!otherUserId) return;
|
||||
if (getJoinedNonFunctionalMembers(room).length !== 2) return;
|
||||
if (!isPresenceEnabled(room.client)) return;
|
||||
|
||||
return room.client.getUser(otherUserId) || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the presence of the DM user.
|
||||
* @param dmUser
|
||||
*/
|
||||
function getPresence(dmUser: User | undefined): Presence {
|
||||
if (!dmUser) return null;
|
||||
if (BUSY_PRESENCE_NAME.matches(dmUser.presence)) return "busy";
|
||||
|
||||
const isOnline = dmUser.currentlyActive || dmUser.presence === "online";
|
||||
if (isOnline) return "online";
|
||||
|
||||
if (dmUser.presence === "offline") return "offline";
|
||||
if (dmUser.presence === "unavailable") return "unavailable";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -21,11 +21,6 @@ import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useStickyRoomList } from "./useStickyRoomList";
|
||||
|
||||
export interface RoomListViewState {
|
||||
/**
|
||||
* Whether the list of rooms is being loaded.
|
||||
*/
|
||||
isLoadingRooms: boolean;
|
||||
|
||||
/**
|
||||
* A list of rooms to be displayed in the left panel.
|
||||
*/
|
||||
@@ -103,7 +98,6 @@ export interface RoomListViewState {
|
||||
export function useRoomListViewModel(): RoomListViewState {
|
||||
const matrixClient = useMatrixClientContext();
|
||||
const {
|
||||
isLoadingRooms,
|
||||
primaryFilters,
|
||||
activePrimaryFilter,
|
||||
rooms: filteredRooms,
|
||||
@@ -126,7 +120,6 @@ export function useRoomListViewModel(): RoomListViewState {
|
||||
const createRoom = useCallback(() => createRoomFunc(currentSpace), [currentSpace]);
|
||||
|
||||
return {
|
||||
isLoadingRooms,
|
||||
rooms,
|
||||
canCreateRoom,
|
||||
createRoom,
|
||||
|
||||
@@ -35,7 +35,6 @@ export interface PrimaryFilter {
|
||||
|
||||
interface FilteredRooms {
|
||||
primaryFilters: PrimaryFilter[];
|
||||
isLoadingRooms: boolean;
|
||||
rooms: Room[];
|
||||
activateSecondaryFilter: (filter: SecondaryFilters) => void;
|
||||
activeSecondaryFilter: SecondaryFilters;
|
||||
@@ -116,7 +115,6 @@ export function useFilteredRooms(): FilteredRooms {
|
||||
);
|
||||
|
||||
const [rooms, setRooms] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace());
|
||||
const [isLoadingRooms, setIsLoadingRooms] = useState(true);
|
||||
|
||||
const updateRoomsFromStore = useCallback((filters: FilterKey[] = []): void => {
|
||||
const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filters);
|
||||
@@ -137,7 +135,6 @@ export function useFilteredRooms(): FilteredRooms {
|
||||
};
|
||||
|
||||
useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => {
|
||||
setIsLoadingRooms(false);
|
||||
const filters = getAppliedFilters();
|
||||
updateRoomsFromStore(filters);
|
||||
});
|
||||
@@ -197,12 +194,5 @@ export function useFilteredRooms(): FilteredRooms {
|
||||
|
||||
const activePrimaryFilter = useMemo(() => primaryFilters.find((filter) => filter.active), [primaryFilters]);
|
||||
|
||||
return {
|
||||
isLoadingRooms,
|
||||
primaryFilters,
|
||||
activePrimaryFilter,
|
||||
rooms,
|
||||
activateSecondaryFilter,
|
||||
activeSecondaryFilter,
|
||||
};
|
||||
return { primaryFilters, activePrimaryFilter, rooms, activateSecondaryFilter, activeSecondaryFilter };
|
||||
}
|
||||
|
||||
@@ -6,156 +6,91 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type ComponentProps } from "react";
|
||||
import {
|
||||
type Room,
|
||||
RoomStateEvent,
|
||||
type MatrixEvent,
|
||||
EventType,
|
||||
RoomType,
|
||||
KnownMembership,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import React, { useCallback, useMemo, type ComponentProps } from "react";
|
||||
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 { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
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 SettingsStore from "../../../settings/SettingsStore";
|
||||
import { useSettingValue } from "../../../hooks/useSettings";
|
||||
import { useRoomState } from "../../../hooks/useRoomState";
|
||||
import { useRoomIdName } from "../../../hooks/room/useRoomIdName";
|
||||
|
||||
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick"> {
|
||||
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick" | "size"> {
|
||||
// Room may be left unset here, but if it is,
|
||||
// oobData.avatarUrl should be set (else there
|
||||
// would be nowhere to get the avatar from)
|
||||
room?: Room;
|
||||
oobData: IOOBData & {
|
||||
// Optional here.
|
||||
size?: ComponentProps<typeof BaseAvatar>["size"];
|
||||
oobData?: IOOBData & {
|
||||
roomId?: string;
|
||||
};
|
||||
viewAvatarOnClick?: boolean;
|
||||
onClick?(): void;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
urls: string[];
|
||||
}
|
||||
const RoomAvatar: React.FC<IProps> = ({ room, viewAvatarOnClick, onClick, oobData, size = "36px", ...otherProps }) => {
|
||||
const roomName = room?.name ?? oobData?.name ?? "?";
|
||||
const avatarEvent = useRoomState(room, (state) => state.getStateEvents(EventType.RoomAvatar, ""));
|
||||
const roomIdName = useRoomIdName(room, oobData);
|
||||
|
||||
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;
|
||||
const showAvatarsOnInvites = useSettingValue("showAvatarsOnInvites", room?.roomId);
|
||||
|
||||
if (room instanceof LocalRoom && room.targets.length === 1) {
|
||||
return room.targets[0].userId;
|
||||
}
|
||||
|
||||
return room.roomId;
|
||||
}
|
||||
|
||||
export default class RoomAvatar extends React.Component<IProps, IState> {
|
||||
public static defaultProps = {
|
||||
size: "36px",
|
||||
oobData: {},
|
||||
};
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
urls: RoomAvatar.getImageUrls(this.props),
|
||||
const onRoomAvatarClick = useCallback(() => {
|
||||
const avatarUrl = Avatar.avatarUrlForRoom(room ?? null);
|
||||
if (!avatarUrl) return;
|
||||
const params = {
|
||||
src: avatarUrl,
|
||||
name: room?.name,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
MatrixClientPeg.safeGet().on(RoomStateEvent.Events, this.onRoomStateEvents);
|
||||
}
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
|
||||
}, [room]);
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
MatrixClientPeg.get()?.removeListener(RoomStateEvent.Events, this.onRoomStateEvents);
|
||||
}
|
||||
|
||||
public static getDerivedStateFromProps(nextProps: IProps): IState {
|
||||
return {
|
||||
urls: RoomAvatar.getImageUrls(nextProps),
|
||||
};
|
||||
}
|
||||
|
||||
private onRoomStateEvents = (ev: MatrixEvent): void => {
|
||||
if (ev.getRoomId() !== this.props.room?.roomId || ev.getType() !== EventType.RoomAvatar) return;
|
||||
|
||||
this.setState({
|
||||
urls: RoomAvatar.getImageUrls(this.props),
|
||||
});
|
||||
};
|
||||
|
||||
private static getImageUrls(props: IProps): string[] {
|
||||
const myMembership = props.room?.getMyMembership();
|
||||
if (myMembership === KnownMembership.Invite || !myMembership) {
|
||||
if (SettingsStore.getValue("showAvatarsOnInvites") === false) {
|
||||
// The user has opted out of showing avatars, so return no urls here.
|
||||
return [];
|
||||
}
|
||||
const urls = useMemo(() => {
|
||||
const myMembership = room?.getMyMembership();
|
||||
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 (props.oobData.avatarUrl) {
|
||||
oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp(
|
||||
parseInt(props.size, 10),
|
||||
parseInt(props.size, 10),
|
||||
"crop",
|
||||
);
|
||||
|
||||
if (oobData?.avatarUrl) {
|
||||
oobAvatar = mediaFromMxc(oobData?.avatarUrl).getThumbnailOfSourceHttp(sizeInt, sizeInt, "crop");
|
||||
}
|
||||
|
||||
return filterBoolean([
|
||||
oobAvatar, // highest priority
|
||||
RoomAvatar.getRoomAvatarUrl(props),
|
||||
Avatar.avatarUrlForRoom(
|
||||
room ?? null,
|
||||
sizeInt,
|
||||
sizeInt,
|
||||
"crop",
|
||||
avatarEvent?.getContent<RoomAvatarEventContent>().url,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}, [showAvatarsOnInvites, room, size, avatarEvent, oobData]);
|
||||
|
||||
private static getRoomAvatarUrl(props: IProps): string | null {
|
||||
if (!props.room) return null;
|
||||
return (
|
||||
<BaseAvatar
|
||||
{...otherProps}
|
||||
size={size}
|
||||
type={(room?.getType() ?? oobData?.roomType) === RoomType.Space ? "square" : "round"}
|
||||
name={roomName}
|
||||
idName={roomIdName}
|
||||
urls={urls}
|
||||
onClick={viewAvatarOnClick && urls[0] ? onRoomAvatarClick : onClick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return Avatar.avatarUrlForRoom(props.room, parseInt(props.size, 10), parseInt(props.size, 10), "crop");
|
||||
}
|
||||
|
||||
private onRoomAvatarClick = (): void => {
|
||||
const avatarUrl = Avatar.avatarUrlForRoom(this.props.room ?? null, undefined, undefined, undefined);
|
||||
if (!avatarUrl) return;
|
||||
const params = {
|
||||
src: avatarUrl,
|
||||
name: this.props.room?.name,
|
||||
};
|
||||
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
|
||||
};
|
||||
|
||||
private get roomIdName(): string | undefined {
|
||||
const room = this.props.room;
|
||||
|
||||
if (room) {
|
||||
return idNameForRoom(room);
|
||||
} else {
|
||||
return this.props.oobData?.roomId;
|
||||
}
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const { room, oobData, viewAvatarOnClick, onClick, ...otherProps } = this.props;
|
||||
const roomName = room?.name ?? oobData.name ?? "?";
|
||||
|
||||
return (
|
||||
<BaseAvatar
|
||||
{...otherProps}
|
||||
type={(room?.getType() ?? this.props.oobData?.roomType) === RoomType.Space ? "square" : "round"}
|
||||
name={roomName}
|
||||
idName={this.roomIdName}
|
||||
urls={this.state.urls}
|
||||
onClick={viewAvatarOnClick && this.state.urls[0] ? this.onRoomAvatarClick : onClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
export default RoomAvatar;
|
||||
|
||||
@@ -15,8 +15,9 @@ import BusyIcon from "@vector-im/compound-design-tokens/assets/web/icons/presenc
|
||||
import classNames from "classnames";
|
||||
|
||||
import RoomAvatar from "./RoomAvatar";
|
||||
import { useRoomAvatarViewModel, type Presence } from "../../viewmodels/avatars/RoomAvatarViewModel";
|
||||
import { useRoomAvatarViewModel } from "../../viewmodels/avatars/RoomAvatarViewModel";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { Presence } from "./WithPresenceIndicator";
|
||||
|
||||
interface RoomAvatarViewProps {
|
||||
/**
|
||||
@@ -83,7 +84,7 @@ type PresenceDecorationProps = {
|
||||
*/
|
||||
function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element {
|
||||
switch (presence) {
|
||||
case "online":
|
||||
case Presence.Online:
|
||||
return (
|
||||
<OnlineOrUnavailableIcon
|
||||
width="8px"
|
||||
@@ -93,7 +94,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
|
||||
aria-label={_t("presence|online")}
|
||||
/>
|
||||
);
|
||||
case "unavailable":
|
||||
case Presence.Away:
|
||||
return (
|
||||
<OnlineOrUnavailableIcon
|
||||
width="8px"
|
||||
@@ -103,7 +104,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
|
||||
aria-label={_t("presence|away")}
|
||||
/>
|
||||
);
|
||||
case "offline":
|
||||
case Presence.Offline:
|
||||
return (
|
||||
<OfflineIcon
|
||||
width="8px"
|
||||
@@ -113,7 +114,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
|
||||
aria-label={_t("presence|offline")}
|
||||
/>
|
||||
);
|
||||
case "busy":
|
||||
case Presence.Busy:
|
||||
return (
|
||||
<BusyIcon
|
||||
width="8px"
|
||||
|
||||
@@ -26,7 +26,7 @@ interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
enum Presence {
|
||||
export enum Presence {
|
||||
// Note: the names here are used in CSS class names
|
||||
Online = "ONLINE",
|
||||
Away = "AWAY",
|
||||
@@ -52,14 +52,14 @@ function getDmMember(room: Room): RoomMember | null {
|
||||
return otherUserId ? room.getMember(otherUserId) : null;
|
||||
}
|
||||
|
||||
export const useDmMember = (room: Room): RoomMember | null => {
|
||||
const [dmMember, setDmMember] = useState<RoomMember | null>(getDmMember(room));
|
||||
export const useDmMember = (room?: Room): RoomMember | null => {
|
||||
const [dmMember, setDmMember] = useState<RoomMember | null>(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;
|
||||
@@ -86,7 +86,7 @@ function getPresence(member: RoomMember | null): Presence | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usePresence = (room: Room, member: RoomMember | null): Presence | null => {
|
||||
export const usePresence = (room: Room, member: RoomMember | null): Presence | null => {
|
||||
const [presence, setPresence] = useState<Presence | null>(getPresence(member));
|
||||
const updatePresence = (): void => {
|
||||
setPresence(getPresence(member));
|
||||
|
||||
@@ -70,7 +70,7 @@ export default class RoomAvatarEvent extends React.Component<IProps> {
|
||||
className="mx_RoomAvatarEvent_avatar"
|
||||
onClick={this.onAvatarClick}
|
||||
>
|
||||
<RoomAvatar size="14px" oobData={oobData} />
|
||||
<RoomAvatar room={room ?? undefined} size="14px" oobData={oobData} />
|
||||
</AccessibleButton>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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<IProps, IState> {
|
||||
private avatarUpload = createRef<HTMLInputElement>();
|
||||
|
||||
@@ -52,6 +52,7 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
|
||||
Icon={InviteIcon}
|
||||
disabled={disabled}
|
||||
aria-label={_t("action|invite")}
|
||||
type="button"
|
||||
/>
|
||||
</OptionalTooltip>
|
||||
);
|
||||
@@ -67,6 +68,7 @@ const InviteButton: React.FC<Props> = ({ vm }) => {
|
||||
className="mx_MemberListHeaderView_invite_large"
|
||||
disabled={!vm.canInvite}
|
||||
onClick={vm.onInviteButtonClick}
|
||||
type="button"
|
||||
>
|
||||
{_t("action|invite")}
|
||||
</Button>
|
||||
|
||||
@@ -11,7 +11,6 @@ import { useRoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewM
|
||||
import { RoomList } from "./RoomList";
|
||||
import { EmptyRoomList } from "./EmptyRoomList";
|
||||
import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
|
||||
import Spinner from "../../elements/Spinner";
|
||||
|
||||
/**
|
||||
* Host the room list and the (future) room filters
|
||||
@@ -19,18 +18,11 @@ import Spinner from "../../elements/Spinner";
|
||||
export function RoomListView(): JSX.Element {
|
||||
const vm = useRoomListViewModel();
|
||||
const isRoomListEmpty = vm.rooms.length === 0;
|
||||
let listBody;
|
||||
if (vm.isLoadingRooms) {
|
||||
listBody = <Spinner />;
|
||||
} else if (isRoomListEmpty) {
|
||||
listBody = <EmptyRoomList vm={vm} />;
|
||||
} else {
|
||||
listBody = <RoomList vm={vm} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoomListPrimaryFilters vm={vm} />
|
||||
{listBody}
|
||||
{isRoomListEmpty ? <EmptyRoomList vm={vm} /> : <RoomList vm={vm} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
32
src/hooks/room/useRoomIdName.ts
Normal file
32
src/hooks/room/useRoomIdName.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo } from "react";
|
||||
import { useState, useCallback, useMemo, useEffect } from "react";
|
||||
|
||||
import type { RoomMember } from "matrix-js-sdk/src/matrix";
|
||||
import { type Call, ConnectionState, CallEvent } from "../models/Call";
|
||||
@@ -20,6 +20,12 @@ export const useCall = (roomId: string): Call | null => {
|
||||
useEventEmitter(CallStore.instance, CallStoreEvent.Call, (call: Call | null, forRoomId: string) => {
|
||||
if (forRoomId === roomId) setCall(call);
|
||||
});
|
||||
|
||||
// Reset the value when the roomId changes
|
||||
useEffect(() => {
|
||||
setCall(CallStore.instance.getCall(roomId));
|
||||
}, [roomId]);
|
||||
|
||||
return call;
|
||||
};
|
||||
|
||||
|
||||
@@ -7,22 +7,19 @@ Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { mocked } from "jest-mock";
|
||||
import { render, screen } from "jest-matrix-react";
|
||||
import parse from "html-react-parser";
|
||||
|
||||
import { bodyToHtml, bodyToNode, formatEmojis, topicToHtml } from "../../src/HtmlUtils";
|
||||
import SettingsStore from "../../src/settings/SettingsStore";
|
||||
|
||||
jest.mock("../../src/settings/SettingsStore");
|
||||
|
||||
const enableHtmlTopicFeature = () => {
|
||||
mocked(SettingsStore).getValue.mockImplementation((arg): any => {
|
||||
return arg === "feature_html_topic";
|
||||
});
|
||||
};
|
||||
import { SettingLevel } from "../../src/settings/SettingLevel";
|
||||
import SdkConfig from "../../src/SdkConfig";
|
||||
|
||||
describe("topicToHtml", () => {
|
||||
afterEach(() => {
|
||||
SettingsStore.reset();
|
||||
});
|
||||
|
||||
function getContent() {
|
||||
return screen.getByRole("contentinfo").children[0].innerHTML;
|
||||
}
|
||||
@@ -38,19 +35,19 @@ describe("topicToHtml", () => {
|
||||
});
|
||||
|
||||
it("converts literal HTML topic to HTML", async () => {
|
||||
enableHtmlTopicFeature();
|
||||
SettingsStore.setValue("feature_html_topic", null, SettingLevel.DEVICE, true);
|
||||
render(<div role="contentinfo">{topicToHtml("<b>pizza</b>", undefined, null, false)}</div>);
|
||||
expect(getContent()).toEqual("<b>pizza</b>");
|
||||
});
|
||||
|
||||
it("converts true HTML topic to HTML", async () => {
|
||||
enableHtmlTopicFeature();
|
||||
SettingsStore.setValue("feature_html_topic", null, SettingLevel.DEVICE, true);
|
||||
render(<div role="contentinfo">{topicToHtml("**pizza**", "<b>pizza</b>", null, false)}</div>);
|
||||
expect(getContent()).toEqual("<b>pizza</b>");
|
||||
});
|
||||
|
||||
it("converts true HTML topic with emoji to HTML", async () => {
|
||||
enableHtmlTopicFeature();
|
||||
SettingsStore.setValue("feature_html_topic", null, SettingLevel.DEVICE, true);
|
||||
render(<div role="contentinfo">{topicToHtml("**pizza** 🍕", "<b>pizza</b> 🍕", null, false)}</div>);
|
||||
expect(getContent()).toEqual('<b>pizza</b> <span class="mx_Emoji" title=":pizza:">🍕</span>');
|
||||
});
|
||||
@@ -107,7 +104,12 @@ describe("bodyToHtml", () => {
|
||||
|
||||
describe("feature_latex_maths", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => feature === "feature_latex_maths");
|
||||
SettingsStore.setValue("feature_latex_maths", null, SettingLevel.DEVICE, true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SettingsStore.reset();
|
||||
SdkConfig.reset();
|
||||
});
|
||||
|
||||
it("should render inline katex", () => {
|
||||
@@ -228,4 +230,8 @@ describe("bodyToNode", () => {
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -484,6 +484,10 @@ describe("<MatrixChat />", () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SettingsStore.reset();
|
||||
});
|
||||
|
||||
it("should persist login credentials", async () => {
|
||||
getComponent({ realQueryParams });
|
||||
|
||||
|
||||
@@ -50,6 +50,8 @@ import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||
import ScrollPanel from "../../../../src/components/structures/ScrollPanel";
|
||||
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../../src/dispatcher/actions";
|
||||
import { SettingLevel } from "../../../../src/settings/SettingLevel";
|
||||
import MatrixClientBackedController from "../../../../src/settings/controllers/MatrixClientBackedController";
|
||||
|
||||
// ScrollPanel calls this, but jsdom doesn't mock it for us
|
||||
HTMLDivElement.prototype.scrollBy = () => {};
|
||||
@@ -310,18 +312,14 @@ describe("TimelinePanel", () => {
|
||||
|
||||
describe("and sending receipts is disabled", () => {
|
||||
beforeEach(async () => {
|
||||
client.isVersionSupported.mockResolvedValue(true);
|
||||
client.doesServerSupportUnstableFeature.mockResolvedValue(true);
|
||||
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting: string): any => {
|
||||
if (setting === "sendReadReceipts") return false;
|
||||
|
||||
return undefined;
|
||||
});
|
||||
// Ensure this setting is supported, otherwise it will use the default value.
|
||||
client.isVersionSupported.mockImplementation(async (v) => v === "v1.4");
|
||||
MatrixClientBackedController.matrixClient = client;
|
||||
SettingsStore.setValue("sendReadReceipts", null, SettingLevel.DEVICE, false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mocked(SettingsStore.getValue).mockReset();
|
||||
SettingsStore.reset();
|
||||
});
|
||||
|
||||
it("should send a fully read marker and a private receipt", async () => {
|
||||
|
||||
@@ -5,32 +5,18 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { renderHook, waitFor, act } from "jest-matrix-react";
|
||||
import {
|
||||
JoinRule,
|
||||
type MatrixClient,
|
||||
MatrixEvent,
|
||||
type Room,
|
||||
type RoomMember,
|
||||
User,
|
||||
UserEvent,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { mocked } from "jest-mock";
|
||||
import { renderHook, waitFor } from "jest-matrix-react";
|
||||
import { JoinRule, type MatrixClient, type Room, RoomMember, User } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { useRoomAvatarViewModel } from "../../../../../src/components/viewmodels/avatars/RoomAvatarViewModel";
|
||||
import { createTestClient, mkStubRoom } from "../../../../test-utils";
|
||||
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
|
||||
import { getJoinedNonFunctionalMembers } from "../../../../../src/utils/room/getJoinedNonFunctionalMembers";
|
||||
import { isPresenceEnabled } from "../../../../../src/utils/presence";
|
||||
import * as PresenceIndicatorModule from "../../../../../src/components/views/avatars/WithPresenceIndicator";
|
||||
|
||||
jest.mock("../../../../../src/utils/room/getJoinedNonFunctionalMembers", () => ({
|
||||
getJoinedNonFunctionalMembers: jest.fn().mockReturnValue([]),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/utils/presence", () => ({
|
||||
isPresenceEnabled: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
describe("RoomAvatarViewModel", () => {
|
||||
let matrixClient: MatrixClient;
|
||||
let room: Room;
|
||||
@@ -41,6 +27,9 @@ describe("RoomAvatarViewModel", () => {
|
||||
|
||||
DMRoomMap.makeShared(matrixClient);
|
||||
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(null);
|
||||
|
||||
jest.spyOn(PresenceIndicatorModule, "useDmMember").mockReturnValue(null);
|
||||
jest.spyOn(PresenceIndicatorModule, "usePresence").mockReturnValue(null);
|
||||
});
|
||||
|
||||
it("should has hasDecoration to false", async () => {
|
||||
@@ -74,62 +63,14 @@ describe("RoomAvatarViewModel", () => {
|
||||
await waitFor(() => expect(vm.current.isPublic).toBe(true));
|
||||
});
|
||||
|
||||
describe("presence", () => {
|
||||
let user: User;
|
||||
it("should return presence", async () => {
|
||||
const user = User.createUser("userId", matrixClient);
|
||||
const roomMember = new RoomMember(room.roomId, "userId");
|
||||
roomMember.user = user;
|
||||
jest.spyOn(PresenceIndicatorModule, "useDmMember").mockReturnValue(roomMember);
|
||||
jest.spyOn(PresenceIndicatorModule, "usePresence").mockReturnValue(PresenceIndicatorModule.Presence.Online);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue("userId");
|
||||
mocked(getJoinedNonFunctionalMembers).mockReturnValue([{}, {}] as RoomMember[]);
|
||||
mocked(isPresenceEnabled).mockReturnValue(true);
|
||||
|
||||
user = User.createUser("userId", matrixClient);
|
||||
jest.spyOn(matrixClient, "getUser").mockReturnValue(user);
|
||||
});
|
||||
|
||||
it("should has presence set to null", () => {
|
||||
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(null);
|
||||
|
||||
const { result: vm } = renderHook(() => useRoomAvatarViewModel(room));
|
||||
expect(vm.current.presence).toBe(null);
|
||||
});
|
||||
|
||||
it("should has online presence", async () => {
|
||||
const { result: vm } = renderHook(() => useRoomAvatarViewModel(room));
|
||||
expect(vm.current.presence).toBe("offline");
|
||||
|
||||
user.presence = "online";
|
||||
|
||||
await act(() => user.emit(UserEvent.Presence, new MatrixEvent(), user));
|
||||
await waitFor(() => expect(vm.current.presence).toBe("online"));
|
||||
|
||||
user.currentlyActive = true;
|
||||
user.presence = "offline";
|
||||
|
||||
await act(() => user.emit(UserEvent.CurrentlyActive, new MatrixEvent(), user));
|
||||
await waitFor(() => expect(vm.current.presence).toBe("online"));
|
||||
});
|
||||
|
||||
it("should has busy presence", async () => {
|
||||
user.presence = "busy";
|
||||
const { result: vm } = renderHook(() => useRoomAvatarViewModel(room));
|
||||
expect(vm.current.presence).toBe("busy");
|
||||
});
|
||||
|
||||
it("should has offline presence", async () => {
|
||||
user.presence = "offline";
|
||||
const { result: vm } = renderHook(() => useRoomAvatarViewModel(room));
|
||||
expect(vm.current.presence).toBe("offline");
|
||||
});
|
||||
|
||||
it("should has unavailable presence", async () => {
|
||||
user.presence = "unavailable";
|
||||
const { result: vm } = renderHook(() => useRoomAvatarViewModel(room));
|
||||
expect(vm.current.presence).toBe("unavailable");
|
||||
});
|
||||
|
||||
it("should has hasDecoration to true", async () => {
|
||||
const { result: vm } = renderHook(() => useRoomAvatarViewModel(room));
|
||||
expect(vm.current.hasDecoration).toBe(true);
|
||||
});
|
||||
const { result: vm } = renderHook(() => useRoomAvatarViewModel(room));
|
||||
expect(vm.current.presence).toBe(PresenceIndicatorModule.Presence.Online);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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";
|
||||
@@ -60,6 +60,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(<RoomAvatar room={room} />).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(<RoomAvatar room={room} />).container).toMatchSnapshot();
|
||||
});
|
||||
it("should not render an invite avatar if the user has disabled it", () => {
|
||||
|
||||
@@ -12,11 +12,11 @@ import { mocked } from "jest-mock";
|
||||
import { RoomAvatarView } from "../../../../../src/components/views/avatars/RoomAvatarView";
|
||||
import { mkStubRoom, stubClient } from "../../../../test-utils";
|
||||
import {
|
||||
type Presence,
|
||||
type RoomAvatarViewState,
|
||||
useRoomAvatarViewModel,
|
||||
} from "../../../../../src/components/viewmodels/avatars/RoomAvatarViewModel";
|
||||
import DMRoomMap from "../../../../../src/utils/DMRoomMap";
|
||||
import { Presence } from "../../../../../src/components/views/avatars/WithPresenceIndicator";
|
||||
|
||||
jest.mock("../../../../../src/components/viewmodels/avatars/RoomAvatarViewModel", () => ({
|
||||
useRoomAvatarViewModel: jest.fn(),
|
||||
@@ -83,10 +83,10 @@ describe("<RoomAvatarView />", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ presence: "online" as Presence, label: "Online" },
|
||||
{ presence: "offline" as Presence, label: "Offline" },
|
||||
{ presence: "busy" as Presence, label: "Busy" },
|
||||
{ presence: "unavailable" as Presence, label: "Away" },
|
||||
{ presence: Presence.Online, label: "Online" },
|
||||
{ presence: Presence.Offline, label: "Offline" },
|
||||
{ presence: Presence.Busy, label: "Busy" },
|
||||
{ presence: Presence.Away, label: "Away" },
|
||||
])("should render the $presence presence", ({ presence, label }) => {
|
||||
mocked(useRoomAvatarViewModel).mockReturnValue({
|
||||
...defaultValue,
|
||||
|
||||
@@ -108,7 +108,76 @@ exports[`<RoomAvatarView /> should render a video room decoration 1`] = `
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<RoomAvatarView /> should render the busy presence 1`] = `
|
||||
exports[`<RoomAvatarView /> should render the AWAY presence 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="mx_RoomAvatarView"
|
||||
>
|
||||
<span
|
||||
aria-label="Avatar"
|
||||
class="_avatar_1qbcf_8 mx_BaseAvatar mx_RoomAvatarView_RoomAvatar mx_RoomAvatarView_RoomAvatar_icon mx_RoomAvatarView_RoomAvatar_presence"
|
||||
data-color="1"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
style="--cpd-avatar-size: 32px;"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="_image_1qbcf_41"
|
||||
data-type="round"
|
||||
height="32px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="http://this.is.a.url/avatar.url/room.png"
|
||||
width="32px"
|
||||
/>
|
||||
</span>
|
||||
<svg
|
||||
aria-label="This room is a video room"
|
||||
class="mx_RoomAvatarView_icon"
|
||||
color="var(--cpd-color-icon-tertiary)"
|
||||
fill="currentColor"
|
||||
height="16px"
|
||||
viewBox="0 0 24 24"
|
||||
width="16px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
aria-label="Away"
|
||||
class="mx_RoomAvatarView_PresenceDecoration"
|
||||
color="var(--cpd-color-icon-quaternary)"
|
||||
fill="currentColor"
|
||||
height="8px"
|
||||
viewBox="0 0 8 8"
|
||||
width="8px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
clip-path="url(#a)"
|
||||
>
|
||||
<path
|
||||
d="M8 4a4 4 0 1 1-8 0 4 4 0 0 1 8 0"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clippath
|
||||
id="a"
|
||||
>
|
||||
<path
|
||||
d="M0 0h8v8H0z"
|
||||
/>
|
||||
</clippath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<RoomAvatarView /> should render the BUSY presence 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="mx_RoomAvatarView"
|
||||
@@ -179,7 +248,7 @@ exports[`<RoomAvatarView /> should render the busy presence 1`] = `
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<RoomAvatarView /> should render the offline presence 1`] = `
|
||||
exports[`<RoomAvatarView /> should render the OFFLINE presence 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="mx_RoomAvatarView"
|
||||
@@ -250,7 +319,7 @@ exports[`<RoomAvatarView /> should render the offline presence 1`] = `
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<RoomAvatarView /> should render the online presence 1`] = `
|
||||
exports[`<RoomAvatarView /> should render the ONLINE presence 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="mx_RoomAvatarView"
|
||||
@@ -318,72 +387,3 @@ exports[`<RoomAvatarView /> should render the online presence 1`] = `
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<RoomAvatarView /> should render the unavailable presence 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="mx_RoomAvatarView"
|
||||
>
|
||||
<span
|
||||
aria-label="Avatar"
|
||||
class="_avatar_1qbcf_8 mx_BaseAvatar mx_RoomAvatarView_RoomAvatar mx_RoomAvatarView_RoomAvatar_icon mx_RoomAvatarView_RoomAvatar_presence"
|
||||
data-color="1"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
style="--cpd-avatar-size: 32px;"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
class="_image_1qbcf_41"
|
||||
data-type="round"
|
||||
height="32px"
|
||||
loading="lazy"
|
||||
referrerpolicy="no-referrer"
|
||||
src="http://this.is.a.url/avatar.url/room.png"
|
||||
width="32px"
|
||||
/>
|
||||
</span>
|
||||
<svg
|
||||
aria-label="This room is a video room"
|
||||
class="mx_RoomAvatarView_icon"
|
||||
color="var(--cpd-color-icon-tertiary)"
|
||||
fill="currentColor"
|
||||
height="16px"
|
||||
viewBox="0 0 24 24"
|
||||
width="16px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 4h10a2 2 0 0 1 2 2v4.286l3.35-2.871a1 1 0 0 1 1.65.76v7.65a1 1 0 0 1-1.65.76L18 13.715V18a2 2 0 0 1-2 2H6a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
aria-label="Away"
|
||||
class="mx_RoomAvatarView_PresenceDecoration"
|
||||
color="var(--cpd-color-icon-quaternary)"
|
||||
fill="currentColor"
|
||||
height="8px"
|
||||
viewBox="0 0 8 8"
|
||||
width="8px"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
clip-path="url(#a)"
|
||||
>
|
||||
<path
|
||||
d="M8 4a4 4 0 1 1-8 0 4 4 0 0 1 8 0"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clippath
|
||||
id="a"
|
||||
>
|
||||
<path
|
||||
d="M0 0h8v8H0z"
|
||||
/>
|
||||
</clippath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2024, 2025 New Vector Ltd.
|
||||
Copyright 2023 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
@@ -57,6 +57,7 @@ import * as UseCall from "../../../../../../src/hooks/useCall";
|
||||
import { SdkContextClass } from "../../../../../../src/contexts/SDKContext";
|
||||
import WidgetStore, { type IApp } from "../../../../../../src/stores/WidgetStore";
|
||||
import { UIFeature } from "../../../../../../src/settings/UIFeature";
|
||||
import { SettingLevel } from "../../../../../../src/settings/SettingLevel";
|
||||
|
||||
jest.mock("../../../../../../src/utils/ShieldUtils");
|
||||
jest.mock("../../../../../../src/hooks/right-panel/useCurrentPhase", () => ({
|
||||
@@ -99,6 +100,7 @@ describe("RoomHeader", () => {
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
SettingsStore.reset();
|
||||
});
|
||||
|
||||
it("renders the room header", () => {
|
||||
@@ -187,9 +189,7 @@ describe("RoomHeader", () => {
|
||||
|
||||
it("opens the notifications panel", async () => {
|
||||
const user = userEvent.setup();
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string): any => {
|
||||
if (name === "feature_notifications") return true;
|
||||
});
|
||||
SettingsStore.setValue("feature_notifications", null, SettingLevel.DEVICE, true);
|
||||
|
||||
render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
@@ -228,7 +228,15 @@ describe("RoomHeader", () => {
|
||||
|
||||
describe("UIFeature.Widgets enabled (default)", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => feature == UIFeature.Widgets);
|
||||
SdkConfig.put({
|
||||
setting_defaults: {
|
||||
[UIFeature.Widgets]: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SdkConfig.reset();
|
||||
});
|
||||
|
||||
it("should show call buttons in a room with 2 members", () => {
|
||||
@@ -248,7 +256,15 @@ describe("RoomHeader", () => {
|
||||
|
||||
describe("UIFeature.Widgets disabled", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => false);
|
||||
SdkConfig.put({
|
||||
setting_defaults: {
|
||||
[UIFeature.Widgets]: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SdkConfig.reset();
|
||||
});
|
||||
|
||||
it("should show call buttons in a room with 2 members", () => {
|
||||
@@ -268,7 +284,15 @@ describe("RoomHeader", () => {
|
||||
|
||||
describe("groups call disabled", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => feature == UIFeature.Widgets);
|
||||
SdkConfig.put({
|
||||
setting_defaults: {
|
||||
[UIFeature.Widgets]: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SdkConfig.reset();
|
||||
});
|
||||
|
||||
it("you can't call if you're alone", () => {
|
||||
@@ -333,15 +357,26 @@ describe("RoomHeader", () => {
|
||||
|
||||
describe("group call enabled", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation(
|
||||
(feature) => feature === "feature_group_calls" || feature == UIFeature.Widgets,
|
||||
);
|
||||
SdkConfig.put({
|
||||
features: {
|
||||
feature_group_calls: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SdkConfig.reset();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders only the video call element", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockRoomMembers(room, 3);
|
||||
jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true });
|
||||
SdkConfig.add({
|
||||
element_call: {
|
||||
use_exclusively: true,
|
||||
},
|
||||
});
|
||||
// allow element calls
|
||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
|
||||
|
||||
@@ -359,7 +394,11 @@ describe("RoomHeader", () => {
|
||||
});
|
||||
|
||||
it("can't call if there's an ongoing (pinned) call", () => {
|
||||
jest.spyOn(SdkConfig, "get").mockReturnValue({ use_exclusively: true });
|
||||
SdkConfig.add({
|
||||
element_call: {
|
||||
use_exclusively: true,
|
||||
},
|
||||
});
|
||||
// allow element calls
|
||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
|
||||
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(true);
|
||||
@@ -377,7 +416,14 @@ describe("RoomHeader", () => {
|
||||
it("clicking on ongoing (unpinned) call re-pins it", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockRoomMembers(room, 3);
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => feature == UIFeature.Widgets);
|
||||
SdkConfig.add({
|
||||
setting_defaults: {
|
||||
[UIFeature.Widgets]: true,
|
||||
},
|
||||
features: {
|
||||
feature_group_calls: false,
|
||||
},
|
||||
});
|
||||
// allow calls
|
||||
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
|
||||
jest.spyOn(WidgetLayoutStore.instance, "isInContainer").mockReturnValue(false);
|
||||
@@ -427,8 +473,10 @@ describe("RoomHeader", () => {
|
||||
jest.spyOn(room.currentState, "maySendStateEvent").mockReturnValue(true);
|
||||
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Invite);
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(false);
|
||||
const guestSpaUrlMock = jest.spyOn(SdkConfig, "get").mockImplementation((key) => {
|
||||
return { guest_spa_url: "https://guest_spa_url.com", url: "https://spa_url.com" };
|
||||
SdkConfig.add({
|
||||
element_call: {
|
||||
guest_spa_url: "https://guest_spa_url.com",
|
||||
},
|
||||
});
|
||||
const { container: containerNoInviteNotPublicCanUpgradeAccess } = render(
|
||||
<RoomHeader room={room} />,
|
||||
@@ -442,8 +490,10 @@ describe("RoomHeader", () => {
|
||||
jest.spyOn(room.currentState, "maySendStateEvent").mockReturnValue(false);
|
||||
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Invite);
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(false);
|
||||
jest.spyOn(SdkConfig, "get").mockImplementation((key) => {
|
||||
return { guest_spa_url: "https://guest_spa_url.com", url: "https://spa_url.com" };
|
||||
SdkConfig.add({
|
||||
element_call: {
|
||||
guest_spa_url: "https://guest_spa_url.com",
|
||||
},
|
||||
});
|
||||
const { container: containerNoInviteNotPublic } = render(<RoomHeader room={room} />, getWrapper());
|
||||
expect(queryAllByLabelText(containerNoInviteNotPublic, "There's no one here to call")).toHaveLength(2);
|
||||
@@ -463,8 +513,9 @@ describe("RoomHeader", () => {
|
||||
const { container: containerInvitePublic } = render(<RoomHeader room={room} />, getWrapper());
|
||||
expect(queryAllByLabelText(containerInvitePublic, "There's no one here to call")).toHaveLength(0);
|
||||
|
||||
// Clear guest_spa_url
|
||||
SdkConfig.reset();
|
||||
// last we can allow everything but without guest_spa_url nothing will work
|
||||
guestSpaUrlMock.mockRestore();
|
||||
const { container: containerAllAllowedButNoGuestSpaUrl } = render(<RoomHeader room={room} />, getWrapper());
|
||||
expect(
|
||||
queryAllByLabelText(containerAllAllowedButNoGuestSpaUrl, "There's no one here to call"),
|
||||
@@ -643,6 +694,10 @@ describe("RoomHeader", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SdkConfig.reset();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[ShieldUtils.E2EStatus.Verified, "Verified"],
|
||||
[ShieldUtils.E2EStatus.Warning, "Untrusted"],
|
||||
@@ -655,6 +710,11 @@ describe("RoomHeader", () => {
|
||||
});
|
||||
|
||||
it("does not show the face pile for DMs", () => {
|
||||
SdkConfig.put({
|
||||
features: {
|
||||
feature_notifications: false,
|
||||
},
|
||||
});
|
||||
const { asFragment } = render(<RoomHeader room={room} />, getWrapper());
|
||||
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
@@ -751,7 +811,7 @@ describe("RoomHeader", () => {
|
||||
|
||||
describe("ask to join enabled", () => {
|
||||
it("does render the RoomKnocksBar", () => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((feature) => feature === "feature_ask_to_join");
|
||||
SettingsStore.setValue("feature_ask_to_join", null, SettingLevel.DEVICE, true);
|
||||
jest.spyOn(room, "canInvite").mockReturnValue(true);
|
||||
jest.spyOn(room, "getJoinRule").mockReturnValue(JoinRule.Knock);
|
||||
jest.spyOn(room, "getMembersWithMembership").mockReturnValue([new RoomMember(room.roomId, "@foo")]);
|
||||
|
||||
@@ -55,7 +55,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
|
||||
style="--cpd-icon-button-size: 100%; --cpd-color-icon-tertiary: var(--cpd-color-icon-disabled);"
|
||||
>
|
||||
<svg
|
||||
aria-labelledby=":r15i:"
|
||||
aria-labelledby=":r1c8:"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
@@ -71,7 +71,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
|
||||
<button
|
||||
aria-disabled="true"
|
||||
aria-label="There's no one here to call"
|
||||
aria-labelledby=":r15n:"
|
||||
aria-labelledby=":r1cd:"
|
||||
class="_icon-button_m2erp_8"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
@@ -96,7 +96,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
|
||||
</button>
|
||||
<button
|
||||
aria-label="Threads"
|
||||
aria-labelledby=":r15s:"
|
||||
aria-labelledby=":r1ci:"
|
||||
class="_icon-button_m2erp_8"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
@@ -122,7 +122,7 @@ exports[`RoomHeader dm does not show the face pile for DMs 1`] = `
|
||||
</button>
|
||||
<button
|
||||
aria-label="Room info"
|
||||
aria-labelledby=":r161:"
|
||||
aria-labelledby=":r1cn:"
|
||||
class="_icon-button_m2erp_8"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 32px;"
|
||||
|
||||
@@ -20,7 +20,6 @@ describe("<EmptyRoomList />", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vm = {
|
||||
isLoadingRooms: false,
|
||||
rooms: [],
|
||||
primaryFilters: [],
|
||||
activateSecondaryFilter: jest.fn().mockReturnValue({}),
|
||||
|
||||
@@ -29,7 +29,6 @@ describe("<RoomList />", () => {
|
||||
matrixClient = stubClient();
|
||||
const rooms = Array.from({ length: 10 }, (_, i) => mkRoom(matrixClient, `room${i}`));
|
||||
vm = {
|
||||
isLoadingRooms: false,
|
||||
rooms,
|
||||
primaryFilters: [],
|
||||
activateSecondaryFilter: () => {},
|
||||
|
||||
@@ -20,7 +20,6 @@ describe("<RoomListPrimaryFilters />", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vm = {
|
||||
isLoadingRooms: false,
|
||||
rooms: [],
|
||||
canCreateRoom: true,
|
||||
createRoom: jest.fn(),
|
||||
|
||||
@@ -24,7 +24,6 @@ jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListViewMode
|
||||
|
||||
describe("<RoomListView />", () => {
|
||||
const defaultValue: RoomListViewState = {
|
||||
isLoadingRooms: false,
|
||||
rooms: [],
|
||||
primaryFilters: [],
|
||||
activateSecondaryFilter: jest.fn().mockReturnValue({}),
|
||||
@@ -44,16 +43,6 @@ describe("<RoomListView />", () => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should render the loading room list", () => {
|
||||
mocked(useRoomListViewModel).mockReturnValue({
|
||||
...defaultValue,
|
||||
isLoadingRooms: true,
|
||||
});
|
||||
|
||||
const roomList = render(<RoomListView />);
|
||||
expect(roomList.container.querySelector(".mx_Spinner")).toBeDefined();
|
||||
});
|
||||
|
||||
it("should render an empty room list", () => {
|
||||
mocked(useRoomListViewModel).mockReturnValue(defaultValue);
|
||||
|
||||
|
||||
@@ -114,15 +114,46 @@ exports[`<RoomListPanel /> should not render the RoomListSearch component when U
|
||||
</li>
|
||||
</ul>
|
||||
<div
|
||||
class="mx_Spinner"
|
||||
class="mx_Flex mx_EmptyRoomList_GenericPlaceholder"
|
||||
data-testid="empty-room-list"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_title"
|
||||
>
|
||||
No chats yet
|
||||
</span>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_description"
|
||||
>
|
||||
Get started by messaging someone
|
||||
</span>
|
||||
<div
|
||||
aria-label="Loading…"
|
||||
class="mx_Spinner_icon"
|
||||
data-testid="spinner"
|
||||
role="progressbar"
|
||||
style="width: 32px; height: 32px;"
|
||||
/>
|
||||
class="mx_Flex mx_EmptyRoomList_DefaultPlaceholder"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57"
|
||||
data-kind="secondary"
|
||||
data-size="sm"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M10 12q-1.65 0-2.825-1.175T6 8t1.175-2.825T10 4t2.825 1.175T14 8t-1.175 2.825T10 12m-8 6v-.8q0-.85.438-1.562.437-.713 1.162-1.088a14.8 14.8 0 0 1 3.15-1.163A13.8 13.8 0 0 1 10 13q1.65 0 3.25.387 1.6.388 3.15 1.163.724.375 1.163 1.087Q18 16.35 18 17.2v.8q0 .824-.587 1.413A1.93 1.93 0 0 1 16 20H4q-.824 0-1.412-.587A1.93 1.93 0 0 1 2 18m2 0h12v-.8a.97.97 0 0 0-.5-.85q-1.35-.675-2.725-1.012a11.6 11.6 0 0 0-5.55 0Q5.85 15.675 4.5 16.35a.97.97 0 0 0-.5.85zm6-8q.825 0 1.412-.588Q12 8.826 12 8q0-.824-.588-1.412A1.93 1.93 0 0 0 10 6q-.825 0-1.412.588A1.93 1.93 0 0 0 8 8q0 .825.588 1.412Q9.175 10 10 10m7 1h2v2q0 .424.288.713.287.287.712.287.424 0 .712-.287A.97.97 0 0 0 21 13v-2h2q.424 0 .712-.287A.97.97 0 0 0 24 10a.97.97 0 0 0-.288-.713A.97.97 0 0 0 23 9h-2V7a.97.97 0 0 0-.288-.713A.97.97 0 0 0 20 6a.97.97 0 0 0-.712.287A.97.97 0 0 0 19 7v2h-2a.97.97 0 0 0-.712.287A.97.97 0 0 0 16 10q0 .424.288.713.287.287.712.287"
|
||||
/>
|
||||
</svg>
|
||||
New message
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</DocumentFragment>
|
||||
@@ -304,15 +335,67 @@ exports[`<RoomListPanel /> should render the RoomListSearch component when UICom
|
||||
</li>
|
||||
</ul>
|
||||
<div
|
||||
class="mx_Spinner"
|
||||
class="mx_Flex mx_EmptyRoomList_GenericPlaceholder"
|
||||
data-testid="empty-room-list"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: stretch; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-2x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_title"
|
||||
>
|
||||
No chats yet
|
||||
</span>
|
||||
<span
|
||||
class="mx_EmptyRoomList_GenericPlaceholder_description"
|
||||
>
|
||||
Get started by messaging someone or by creating a room
|
||||
</span>
|
||||
<div
|
||||
aria-label="Loading…"
|
||||
class="mx_Spinner_icon"
|
||||
data-testid="spinner"
|
||||
role="progressbar"
|
||||
style="width: 32px; height: 32px;"
|
||||
/>
|
||||
class="mx_Flex mx_EmptyRoomList_DefaultPlaceholder"
|
||||
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x); --mx-flex-wrap: nowrap;"
|
||||
>
|
||||
<button
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57"
|
||||
data-kind="secondary"
|
||||
data-size="sm"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M10 12q-1.65 0-2.825-1.175T6 8t1.175-2.825T10 4t2.825 1.175T14 8t-1.175 2.825T10 12m-8 6v-.8q0-.85.438-1.562.437-.713 1.162-1.088a14.8 14.8 0 0 1 3.15-1.163A13.8 13.8 0 0 1 10 13q1.65 0 3.25.387 1.6.388 3.15 1.163.724.375 1.163 1.087Q18 16.35 18 17.2v.8q0 .824-.587 1.413A1.93 1.93 0 0 1 16 20H4q-.824 0-1.412-.587A1.93 1.93 0 0 1 2 18m2 0h12v-.8a.97.97 0 0 0-.5-.85q-1.35-.675-2.725-1.012a11.6 11.6 0 0 0-5.55 0Q5.85 15.675 4.5 16.35a.97.97 0 0 0-.5.85zm6-8q.825 0 1.412-.588Q12 8.826 12 8q0-.824-.588-1.412A1.93 1.93 0 0 0 10 6q-.825 0-1.412.588A1.93 1.93 0 0 0 8 8q0 .825.588 1.412Q9.175 10 10 10m7 1h2v2q0 .424.288.713.287.287.712.287.424 0 .712-.287A.97.97 0 0 0 21 13v-2h2q.424 0 .712-.287A.97.97 0 0 0 24 10a.97.97 0 0 0-.288-.713A.97.97 0 0 0 23 9h-2V7a.97.97 0 0 0-.288-.713A.97.97 0 0 0 20 6a.97.97 0 0 0-.712.287A.97.97 0 0 0 19 7v2h-2a.97.97 0 0 0-.712.287A.97.97 0 0 0 16 10q0 .424.288.713.287.287.712.287"
|
||||
/>
|
||||
</svg>
|
||||
New message
|
||||
</button>
|
||||
<button
|
||||
class="_button_vczzf_8 _has-icon_vczzf_57"
|
||||
data-kind="secondary"
|
||||
data-size="sm"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m8.566 17-.944 4.094q-.086.406-.372.656t-.687.25q-.543 0-.887-.469a1.18 1.18 0 0 1-.2-1.031l.801-3.5H3.158q-.572 0-.916-.484a1.27 1.27 0 0 1-.2-1.078 1.12 1.12 0 0 1 1.116-.938H6.85l1.145-5h-3.12q-.57 0-.915-.484a1.27 1.27 0 0 1-.2-1.078A1.12 1.12 0 0 1 4.875 7h3.691l.945-4.094q.085-.406.372-.656.286-.25.686-.25.544 0 .887.469.345.468.2 1.031l-.8 3.5h4.578l.944-4.094q.085-.406.372-.656.286-.25.687-.25.543 0 .887.469t.2 1.031L17.723 7h3.119q.573 0 .916.484.343.485.2 1.079a1.12 1.12 0 0 1-1.116.937H17.15l-1.145 5h3.12q.57 0 .915.484.343.485.2 1.079a1.12 1.12 0 0 1-1.116.937h-3.691l-.944 4.094q-.087.406-.373.656t-.686.25q-.544 0-.887-.469a1.18 1.18 0 0 1-.2-1.031l.8-3.5zm.573-2.5h4.578l1.144-5h-4.578z"
|
||||
/>
|
||||
</svg>
|
||||
New room
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</DocumentFragment>
|
||||
|
||||
@@ -37,6 +37,10 @@ describe("NotificatinSettingsTab", () => {
|
||||
NotificationSettingsTab.contextType = React.createContext<MatrixClient>(cli);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SettingsStore.reset();
|
||||
});
|
||||
|
||||
it("should prevent »Settings« link click from bubbling up to radio buttons", async () => {
|
||||
const tab = renderTab();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2024, 2025 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
@@ -28,13 +28,13 @@ import {
|
||||
getMarkedUnreadState,
|
||||
setMarkedUnreadState,
|
||||
} from "../../../src/utils/notifications";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
import { getMockClientWithEventEmitter } from "../../test-utils/client";
|
||||
import { getMockClientWithEventEmitter, mockClientMethodsServer } from "../../test-utils/client";
|
||||
import { mkMessage, stubClient } from "../../test-utils/test-utils";
|
||||
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
|
||||
import { NotificationLevel } from "../../../src/stores/notifications/NotificationLevel";
|
||||
|
||||
jest.mock("../../../src/settings/SettingsStore");
|
||||
import { SettingLevel } from "../../../src/settings/SettingLevel";
|
||||
import MatrixClientBackedController from "../../../src/settings/controllers/MatrixClientBackedController";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
|
||||
describe("notifications", () => {
|
||||
let accountDataStore: Record<string, MatrixEvent> = {};
|
||||
@@ -44,6 +44,7 @@ describe("notifications", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockClient = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsServer(),
|
||||
isGuest: jest.fn().mockReturnValue(false),
|
||||
getAccountData: jest.fn().mockImplementation((eventType) => accountDataStore[eventType]),
|
||||
setAccountData: jest.fn().mockImplementation((eventType, content) => {
|
||||
@@ -52,10 +53,20 @@ describe("notifications", () => {
|
||||
content,
|
||||
});
|
||||
}),
|
||||
isVersionSupported: jest.fn().mockImplementation(async (v) => v === "v1.4"),
|
||||
});
|
||||
|
||||
// Ensure unstable settings are supported, otherwise it will use the default value.
|
||||
MatrixClientBackedController.matrixClient = mockClient;
|
||||
accountDataStore = {};
|
||||
accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId!);
|
||||
mocked(SettingsStore).getValue.mockReturnValue(false);
|
||||
// Disable all notifications
|
||||
deviceNotificationSettingsKeys.forEach((k) => SettingsStore.setValue(k, null, SettingLevel.DEVICE, false));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
SettingsStore.reset();
|
||||
});
|
||||
|
||||
describe("createLocalNotification", () => {
|
||||
@@ -75,10 +86,15 @@ describe("notifications", () => {
|
||||
it.each(deviceNotificationSettingsKeys)(
|
||||
"unsilenced for existing sessions when %s setting is truthy",
|
||||
async (settingKey) => {
|
||||
mocked(SettingsStore).getValue.mockImplementation((key): any => {
|
||||
return key === settingKey;
|
||||
// We need to spy `getValue` because setting these keys requires mocking
|
||||
// the platform to support notifications, which is out of scope for this test.
|
||||
const origFn = SettingsStore.getValue;
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((name, ...args) => {
|
||||
if (name === settingKey) {
|
||||
return true;
|
||||
}
|
||||
return origFn(name, ...args);
|
||||
});
|
||||
|
||||
await createLocalNotificationSettingsIfNeeded(mockClient);
|
||||
const event = mockClient.getAccountData(accountDataEventKey);
|
||||
expect(event?.getContent().is_silenced).toBe(false);
|
||||
@@ -116,7 +132,6 @@ describe("notifications", () => {
|
||||
const ROOM_ID = "123";
|
||||
const USER_ID = "@bob:example.org";
|
||||
let message: MatrixEvent;
|
||||
let sendReceiptsSetting = true;
|
||||
|
||||
beforeEach(() => {
|
||||
stubClient();
|
||||
@@ -131,9 +146,7 @@ describe("notifications", () => {
|
||||
room.addLiveEvents([message], { addToState: true });
|
||||
sendReadReceiptSpy = jest.spyOn(client, "sendReadReceipt").mockResolvedValue({});
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room]);
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => {
|
||||
return name === "sendReadReceipts" && sendReceiptsSetting;
|
||||
});
|
||||
SettingsStore.setValue("sendReadReceipts", null, SettingLevel.DEVICE, true);
|
||||
});
|
||||
|
||||
it("sends a request even if everything has been read", async () => {
|
||||
@@ -152,11 +165,8 @@ describe("notifications", () => {
|
||||
});
|
||||
|
||||
describe("when sendReadReceipts setting is disabled", () => {
|
||||
beforeEach(() => {
|
||||
sendReceiptsSetting = false;
|
||||
});
|
||||
|
||||
it("should send a private read receipt", async () => {
|
||||
SettingsStore.setValue("sendReadReceipts", null, SettingLevel.DEVICE, false);
|
||||
await clearRoomNotification(room, client);
|
||||
expect(sendReadReceiptSpy).toHaveBeenCalledWith(message, ReceiptType.ReadPrivate, true);
|
||||
});
|
||||
@@ -177,9 +187,7 @@ describe("notifications", () => {
|
||||
room = new Room(ROOM_ID, client, USER_ID);
|
||||
sendReadReceiptSpy = jest.spyOn(client, "sendReadReceipt").mockResolvedValue({});
|
||||
jest.spyOn(client, "getRooms").mockReturnValue([room]);
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => {
|
||||
return name === "sendReadReceipts";
|
||||
});
|
||||
SettingsStore.setValue("sendReadReceipts", null, SettingLevel.DEVICE, true);
|
||||
});
|
||||
|
||||
it("does not send any requests if everything has been read", () => {
|
||||
@@ -212,7 +220,7 @@ describe("notifications", () => {
|
||||
room.addLiveEvents([message], { addToState: true });
|
||||
room.setUnreadNotificationCount(NotificationCountType.Total, 1);
|
||||
|
||||
jest.spyOn(SettingsStore, "getValue").mockReset().mockReturnValue(false);
|
||||
SettingsStore.setValue("sendReadReceipts", null, SettingLevel.DEVICE, false);
|
||||
|
||||
await clearAllNotifications(client);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user