Merge branch 'develop' into johannes/latest-room-in-space

This commit is contained in:
Johannes Marbach 2023-02-02 20:21:17 +01:00
commit f3fd027db4
40 changed files with 948 additions and 868 deletions

View file

@ -77,7 +77,7 @@ describe("Spaces", () => {
cy.stopHomeserver(homeserver); cy.stopHomeserver(homeserver);
}); });
it.only("should allow user to create public space", () => { it("should allow user to create public space", () => {
openSpaceCreateMenu(); openSpaceCreateMenu();
cy.get("#mx_ContextualMenu_Container").percySnapshotElement("Space create menu"); cy.get("#mx_ContextualMenu_Container").percySnapshotElement("Space create menu");
cy.get(".mx_SpaceCreateMenu_wrapper .mx_ContextualMenu").within(() => { cy.get(".mx_SpaceCreateMenu_wrapper .mx_ContextualMenu").within(() => {
@ -153,7 +153,10 @@ describe("Spaces", () => {
openSpaceCreateMenu().within(() => { openSpaceCreateMenu().within(() => {
cy.get(".mx_SpaceCreateMenuType_private").click(); cy.get(".mx_SpaceCreateMenuType_private").click();
// We don't set an avatar here to get a Percy snapshot of the default avatar style for spaces cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]').selectFile(
"cypress/fixtures/riot.png",
{ force: true },
);
cy.get('input[label="Address"]').should("not.exist"); cy.get('input[label="Address"]').should("not.exist");
cy.get('textarea[label="Description"]').type("This is a personal space to mourn Riot.im..."); cy.get('textarea[label="Description"]').type("This is a personal space to mourn Riot.im...");
cy.get('input[label="Name"]').type("This is my Riot{enter}"); cy.get('input[label="Name"]').type("This is my Riot{enter}");
@ -166,7 +169,6 @@ describe("Spaces", () => {
cy.contains(".mx_RoomList .mx_RoomTile", "Sample Room").should("exist"); cy.contains(".mx_RoomList .mx_RoomTile", "Sample Room").should("exist");
cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "Sample Room").should("exist"); cy.contains(".mx_SpaceHierarchy_list .mx_SpaceHierarchy_roomTile", "Sample Room").should("exist");
cy.get(".mx_LeftPanel_outerWrapper").percySnapshotElement("Left panel with default avatar space");
}); });
it("should allow user to invite another to a space", () => { it("should allow user to invite another to a space", () => {

View file

@ -277,11 +277,14 @@ $activeBorderColor: $primary-content;
.mx_BaseAvatar:not(.mx_UserMenu_userAvatar_BaseAvatar) .mx_BaseAvatar_initial { .mx_BaseAvatar:not(.mx_UserMenu_userAvatar_BaseAvatar) .mx_BaseAvatar_initial {
color: $secondary-content; color: $secondary-content;
border-radius: 8px; border-radius: 8px;
background-color: $panel-actions;
font-size: $font-15px !important; /* override inline style */
font-weight: $font-semi-bold; font-weight: $font-semi-bold;
line-height: $font-18px; line-height: $font-18px;
/* override inline styles which are part of the default avatar style as these uses a monochrome style */
background-color: $panel-actions !important; & + .mx_BaseAvatar_image {
font-size: $font-15px !important; visibility: hidden;
}
} }
.mx_SpaceTreeLevel { .mx_SpaceTreeLevel {

View file

@ -16,7 +16,16 @@ limitations under the License.
.mx_BaseAvatar { .mx_BaseAvatar {
position: relative; position: relative;
display: block; /* In at least Firefox, the case of relative positioned inline elements */
/* (such as mx_BaseAvatar) with absolute positioned children (such as */
/* mx_BaseAvatar_initial) is a dark corner full of spider webs. It will give */
/* different results during full reflow of the page vs. incremental reflow */
/* of small portions. While that's surely a browser bug, we can avoid it by */
/* using `inline-block` instead of the default `inline`. */
/* https://github.com/vector-im/element-web/issues/5594 */
/* https://bugzilla.mozilla.org/show_bug.cgi?id=1535053 */
/* https://bugzilla.mozilla.org/show_bug.cgi?id=255139 */
display: inline-block;
user-select: none; user-select: none;
&.mx_RoomAvatar_isSpaceRoom { &.mx_RoomAvatar_isSpaceRoom {

View file

@ -267,3 +267,7 @@ limitations under the License.
.mx_RoomSummaryCard_icon_export::before { .mx_RoomSummaryCard_icon_export::before {
mask-image: url("$(res)/img/element-icons/export.svg"); mask-image: url("$(res)/img/element-icons/export.svg");
} }
.mx_RoomSummaryCard_icon_poll::before {
mask-image: url("$(res)/img/element-icons/room/composer/poll.svg");
}

View file

@ -78,7 +78,7 @@ limitations under the License.
min-width: $font-16px; /* ensure the avatar is not compressed */ min-width: $font-16px; /* ensure the avatar is not compressed */
height: $font-16px; height: $font-16px;
margin-inline-end: 0.24rem; margin-inline-end: 0.24rem;
background: var(--avatar-background); background: var(--avatar-background), $background;
color: $avatar-initial-color; color: $avatar-initial-color;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: $font-16px; background-size: $font-16px;

View file

@ -635,7 +635,7 @@ $left-gutter: 64px;
} }
/* Make list type disc to match rich text editor */ /* Make list type disc to match rich text editor */
> ul { ul {
list-style-type: disc; list-style-type: disc;
} }

View file

@ -58,6 +58,11 @@ limitations under the License.
padding-inline-start: $spacing-28; padding-inline-start: $spacing-28;
} }
/* Make list type disc to match rich text editor */
ul {
list-style-type: disc;
}
blockquote { blockquote {
color: #777; color: #777;
border-left: 2px solid $blockquote-bar-color; border-left: 2px solid $blockquote-bar-color;
@ -90,6 +95,11 @@ limitations under the License.
border-radius: 4px; border-radius: 4px;
padding: $spacing-2; padding: $spacing-2;
} }
code:empty {
border: unset;
padding: unset;
}
} }
.mx_WysiwygComposer_Editor_content_placeholder::before { .mx_WysiwygComposer_Editor_content_placeholder::before {

View file

@ -10,5 +10,5 @@ sonar.exclusions=__mocks__,docs
sonar.typescript.tsconfigPath=./tsconfig.json sonar.typescript.tsconfigPath=./tsconfig.json
sonar.javascript.lcov.reportPaths=coverage/lcov.info sonar.javascript.lcov.reportPaths=coverage/lcov.info
sonar.coverage.exclusions=test/**/*,cypress/**/* sonar.coverage.exclusions=test/**/*,cypress/**/*,src/components/views/dialogs/devtools/**/*
sonar.testExecutionReportPaths=coverage/jest-sonar-report.xml sonar.testExecutionReportPaths=coverage/jest-sonar-report.xml

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2015, 2016, 2023 The Matrix.org Foundation C.I.C. Copyright 2015, 2016 OpenMarket Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -24,19 +24,16 @@ import DMRoomMap from "./utils/DMRoomMap";
import { mediaFromMxc } from "./customisations/Media"; import { mediaFromMxc } from "./customisations/Media";
import { isLocalRoom } from "./utils/localRoom/isLocalRoom"; import { isLocalRoom } from "./utils/localRoom/isLocalRoom";
const DEFAULT_COLORS: Readonly<string[]> = ["#0DBD8B", "#368bd6", "#ac3ba8"];
// Not to be used for BaseAvatar urls as that has similar default avatar fallback already // Not to be used for BaseAvatar urls as that has similar default avatar fallback already
export function avatarUrlForMember( export function avatarUrlForMember(
member: RoomMember | null | undefined, member: RoomMember,
width: number, width: number,
height: number, height: number,
resizeMethod: ResizeMethod, resizeMethod: ResizeMethod,
): string { ): string {
let url: string | undefined; let url: string;
const mxcUrl = member?.getMxcAvatarUrl(); if (member?.getMxcAvatarUrl()) {
if (mxcUrl) { url = mediaFromMxc(member.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
url = mediaFromMxc(mxcUrl).getThumbnailOfSourceHttp(width, height, resizeMethod);
} }
if (!url) { if (!url) {
// member can be null here currently since on invites, the JS SDK // member can be null here currently since on invites, the JS SDK
@ -47,17 +44,6 @@ export function avatarUrlForMember(
return url; return url;
} }
export function getMemberAvatar(
member: RoomMember | null | undefined,
width: number,
height: number,
resizeMethod: ResizeMethod,
): string | undefined {
const mxcUrl = member?.getMxcAvatarUrl();
if (!mxcUrl) return undefined;
return mediaFromMxc(mxcUrl).getThumbnailOfSourceHttp(width, height, resizeMethod);
}
export function avatarUrlForUser( export function avatarUrlForUser(
user: Pick<User, "avatarUrl">, user: Pick<User, "avatarUrl">,
width: number, width: number,
@ -100,10 +86,18 @@ function urlForColor(color: string): string {
// hard to install a listener here, even if there were a clear event to listen to // hard to install a listener here, even if there were a clear event to listen to
const colorToDataURLCache = new Map<string, string>(); const colorToDataURLCache = new Map<string, string>();
export function defaultAvatarUrlForString(s: string | undefined): string { export function defaultAvatarUrlForString(s: string): string {
if (!s) return ""; // XXX: should never happen but empirically does by evidence of a rageshake if (!s) return ""; // XXX: should never happen but empirically does by evidence of a rageshake
const defaultColors = ["#0DBD8B", "#368bd6", "#ac3ba8"];
const color = getColorForString(s); let total = 0;
for (let i = 0; i < s.length; ++i) {
total += s.charCodeAt(i);
}
const colorIndex = total % defaultColors.length;
// overwritten color value in custom themes
const cssVariable = `--avatar-background-colors_${colorIndex}`;
const cssValue = document.body.style.getPropertyValue(cssVariable);
const color = cssValue || defaultColors[colorIndex];
let dataUrl = colorToDataURLCache.get(color); let dataUrl = colorToDataURLCache.get(color);
if (!dataUrl) { if (!dataUrl) {
// validate color as this can come from account_data // validate color as this can come from account_data
@ -118,23 +112,13 @@ export function defaultAvatarUrlForString(s: string | undefined): string {
return dataUrl; return dataUrl;
} }
export function getColorForString(input: string): string {
const charSum = [...input].reduce((s, c) => s + c.charCodeAt(0), 0);
const index = charSum % DEFAULT_COLORS.length;
// overwritten color value in custom themes
const cssVariable = `--avatar-background-colors_${index}`;
const cssValue = document.body.style.getPropertyValue(cssVariable);
return cssValue || DEFAULT_COLORS[index]!;
}
/** /**
* returns the first (non-sigil) character of 'name', * returns the first (non-sigil) character of 'name',
* converted to uppercase * converted to uppercase
* @param {string} name * @param {string} name
* @return {string} the first letter * @return {string} the first letter
*/ */
export function getInitialLetter(name: string): string | undefined { export function getInitialLetter(name: string): string {
if (!name) { if (!name) {
// XXX: We should find out what causes the name to sometimes be falsy. // XXX: We should find out what causes the name to sometimes be falsy.
console.trace("`name` argument to `getInitialLetter` not supplied"); console.trace("`name` argument to `getInitialLetter` not supplied");
@ -150,20 +134,19 @@ export function getInitialLetter(name: string): string | undefined {
} }
// rely on the grapheme cluster splitter in lodash so that we don't break apart compound emojis // rely on the grapheme cluster splitter in lodash so that we don't break apart compound emojis
return split(name, "", 1)[0]!.toUpperCase(); return split(name, "", 1)[0].toUpperCase();
} }
export function avatarUrlForRoom( export function avatarUrlForRoom(
room: Room | undefined, room: Room,
width: number, width: number,
height: number, height: number,
resizeMethod?: ResizeMethod, resizeMethod?: ResizeMethod,
): string | null { ): string | null {
if (!room) return null; // null-guard if (!room) return null; // null-guard
const mxcUrl = room.getMxcAvatarUrl(); if (room.getMxcAvatarUrl()) {
if (mxcUrl) { return mediaFromMxc(room.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
return mediaFromMxc(mxcUrl).getThumbnailOfSourceHttp(width, height, resizeMethod);
} }
// space rooms cannot be DMs so skip the rest // space rooms cannot be DMs so skip the rest
@ -176,9 +159,8 @@ export function avatarUrlForRoom(
// If there are only two members in the DM use the avatar of the other member // If there are only two members in the DM use the avatar of the other member
const otherMember = room.getAvatarFallbackMember(); const otherMember = room.getAvatarFallbackMember();
const otherMemberMxc = otherMember?.getMxcAvatarUrl(); if (otherMember?.getMxcAvatarUrl()) {
if (otherMemberMxc) { return mediaFromMxc(otherMember.getMxcAvatarUrl()).getThumbnailOfSourceHttp(width, height, resizeMethod);
return mediaFromMxc(otherMemberMxc).getThumbnailOfSourceHttp(width, height, resizeMethod);
} }
return null; return null;
} }

View file

@ -1,6 +1,8 @@
/* /*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com> Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2015, 2016, 2018, 2019, 2020, 2023 The Matrix.org Foundation C.I.C. Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,46 +17,38 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { CSSProperties, useCallback, useContext, useEffect, useState } from "react"; import React, { useCallback, useContext, useEffect, useState } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials"; import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
import { ClientEvent } from "matrix-js-sdk/src/client"; import { ClientEvent } from "matrix-js-sdk/src/client";
import { SyncState } from "matrix-js-sdk/src/sync";
import * as AvatarLogic from "../../../Avatar"; import * as AvatarLogic from "../../../Avatar";
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import RoomContext from "../../../contexts/RoomContext"; import RoomContext from "../../../contexts/RoomContext";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import { toPx } from "../../../utils/units"; import { toPx } from "../../../utils/units";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
interface IProps { interface IProps {
/** The name (first initial used as default) */ name: string; // The name (first initial used as default)
name: string; idName?: string; // ID for generating hash colours
/** ID for generating hash colours */ title?: string; // onHover title text
idName?: string; url?: string; // highest priority of them all, shortcut to set in urls[0]
/** onHover title text */ urls?: string[]; // [highest_priority, ... , lowest_priority]
title?: string;
/** highest priority of them all, shortcut to set in urls[0] */
url?: string;
/** [highest_priority, ... , lowest_priority] */
urls?: string[];
width?: number; width?: number;
height?: number; height?: number;
/** @deprecated not actually used */ // XXX: resizeMethod not actually used.
resizeMethod?: ResizeMethod; resizeMethod?: ResizeMethod;
/** true to add default url */ defaultToInitialLetter?: boolean; // true to add default url
defaultToInitialLetter?: boolean; onClick?: React.MouseEventHandler;
onClick?: React.ComponentPropsWithoutRef<typeof AccessibleTooltipButton>["onClick"];
inputRef?: React.RefObject<HTMLImageElement & HTMLSpanElement>; inputRef?: React.RefObject<HTMLImageElement & HTMLSpanElement>;
className?: string; className?: string;
tabIndex?: number; tabIndex?: number;
style?: CSSProperties;
} }
const calculateUrls = (url: string | undefined, urls: string[] | undefined, lowBandwidth: boolean): string[] => { const calculateUrls = (url: string, urls: string[], lowBandwidth: boolean): string[] => {
// work out the full set of urls to try to load. This is formed like so: // work out the full set of urls to try to load. This is formed like so:
// imageUrls: [ props.url, ...props.urls ] // imageUrls: [ props.url, ...props.urls ]
@ -72,26 +66,11 @@ const calculateUrls = (url: string | undefined, urls: string[] | undefined, lowB
return Array.from(new Set(_urls)); return Array.from(new Set(_urls));
}; };
/** const useImageUrl = ({ url, urls }): [string, () => void] => {
* Hook for cycling through a changing set of images.
*
* The set of images is updated whenever `url` or `urls` change, the user's
* `lowBandwidth` preference changes, or the client reconnects.
*
* Returns `[imageUrl, onError]`. When `onError` is called, the next image in
* the set will be displayed.
*/
const useImageUrl = ({
url,
urls,
}: {
url: string | undefined;
urls: string[] | undefined;
}): [string | undefined, () => void] => {
// Since this is a hot code path and the settings store can be slow, we // Since this is a hot code path and the settings store can be slow, we
// use the cached lowBandwidth value from the room context if it exists // use the cached lowBandwidth value from the room context if it exists
const roomContext = useContext(RoomContext); const roomContext = useContext(RoomContext);
const lowBandwidth = roomContext.lowBandwidth; const lowBandwidth = roomContext ? roomContext.lowBandwidth : SettingsStore.getValue("lowBandwidth");
const [imageUrls, setUrls] = useState<string[]>(calculateUrls(url, urls, lowBandwidth)); const [imageUrls, setUrls] = useState<string[]>(calculateUrls(url, urls, lowBandwidth));
const [urlsIndex, setIndex] = useState<number>(0); const [urlsIndex, setIndex] = useState<number>(0);
@ -106,10 +85,10 @@ const useImageUrl = ({
}, [url, JSON.stringify(urls)]); // eslint-disable-line react-hooks/exhaustive-deps }, [url, JSON.stringify(urls)]); // eslint-disable-line react-hooks/exhaustive-deps
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const onClientSync = useCallback((syncState: SyncState, prevState: SyncState | null) => { const onClientSync = useCallback((syncState, prevState) => {
// Consider the client reconnected if there is no error with syncing. // Consider the client reconnected if there is no error with syncing.
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP. // This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
const reconnected = syncState !== SyncState.Error && prevState !== syncState; const reconnected = syncState !== "ERROR" && prevState !== syncState;
if (reconnected) { if (reconnected) {
setIndex(0); setIndex(0);
} }
@ -129,25 +108,46 @@ const BaseAvatar: React.FC<IProps> = (props) => {
urls, urls,
width = 40, width = 40,
height = 40, height = 40,
resizeMethod = "crop", // eslint-disable-line @typescript-eslint/no-unused-vars
defaultToInitialLetter = true, defaultToInitialLetter = true,
onClick, onClick,
inputRef, inputRef,
className, className,
style: parentStyle,
resizeMethod: _unused, // to keep it from being in `otherProps`
...otherProps ...otherProps
} = props; } = props;
const style = {
...parentStyle,
width: toPx(width),
height: toPx(height),
};
const [imageUrl, onError] = useImageUrl({ url, urls }); const [imageUrl, onError] = useImageUrl({ url, urls });
if (!imageUrl && defaultToInitialLetter && name) { if (!imageUrl && defaultToInitialLetter && name) {
const avatar = <TextAvatar name={name} idName={idName} width={width} height={height} title={title} />; const initialLetter = AvatarLogic.getInitialLetter(name);
const textNode = (
<span
className="mx_BaseAvatar_initial"
aria-hidden="true"
style={{
fontSize: toPx(width * 0.65),
width: toPx(width),
lineHeight: toPx(height),
}}
>
{initialLetter}
</span>
);
const imgNode = (
<img
className="mx_BaseAvatar_image"
src={AvatarLogic.defaultAvatarUrlForString(idName || name)}
alt=""
title={title}
onError={onError}
style={{
width: toPx(width),
height: toPx(height),
}}
aria-hidden="true"
data-testid="avatar-img"
/>
);
if (onClick) { if (onClick) {
return ( return (
@ -159,9 +159,9 @@ const BaseAvatar: React.FC<IProps> = (props) => {
className={classNames("mx_BaseAvatar", className)} className={classNames("mx_BaseAvatar", className)}
onClick={onClick} onClick={onClick}
inputRef={inputRef} inputRef={inputRef}
style={style}
> >
{avatar} {textNode}
{imgNode}
</AccessibleButton> </AccessibleButton>
); );
} else { } else {
@ -170,10 +170,10 @@ const BaseAvatar: React.FC<IProps> = (props) => {
className={classNames("mx_BaseAvatar", className)} className={classNames("mx_BaseAvatar", className)}
ref={inputRef} ref={inputRef}
{...otherProps} {...otherProps}
style={style}
role="presentation" role="presentation"
> >
{avatar} {textNode}
{imgNode}
</span> </span>
); );
} }
@ -187,7 +187,10 @@ const BaseAvatar: React.FC<IProps> = (props) => {
src={imageUrl} src={imageUrl}
onClick={onClick} onClick={onClick}
onError={onError} onError={onError}
style={style} style={{
width: toPx(width),
height: toPx(height),
}}
title={title} title={title}
alt={_t("Avatar")} alt={_t("Avatar")}
inputRef={inputRef} inputRef={inputRef}
@ -201,7 +204,10 @@ const BaseAvatar: React.FC<IProps> = (props) => {
className={classNames("mx_BaseAvatar mx_BaseAvatar_image", className)} className={classNames("mx_BaseAvatar mx_BaseAvatar_image", className)}
src={imageUrl} src={imageUrl}
onError={onError} onError={onError}
style={style} style={{
width: toPx(width),
height: toPx(height),
}}
title={title} title={title}
alt="" alt=""
ref={inputRef} ref={inputRef}
@ -214,31 +220,3 @@ const BaseAvatar: React.FC<IProps> = (props) => {
export default BaseAvatar; export default BaseAvatar;
export type BaseAvatarType = React.FC<IProps>; export type BaseAvatarType = React.FC<IProps>;
const TextAvatar: React.FC<{
name: string;
idName?: string;
width: number;
height: number;
title?: string;
}> = ({ name, idName, width, height, title }) => {
const initialLetter = AvatarLogic.getInitialLetter(name);
return (
<span
className="mx_BaseAvatar_image mx_BaseAvatar_initial"
aria-hidden="true"
style={{
backgroundColor: AvatarLogic.getColorForString(idName || name),
width: toPx(width),
height: toPx(height),
fontSize: toPx(width * 0.65),
lineHeight: toPx(height),
}}
title={title}
data-testid="avatar-img"
>
{initialLetter}
</span>
);
};

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2015, 2016, 2019 - 2023 The Matrix.org Foundation C.I.C. Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 - 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -25,7 +26,6 @@ import { mediaFromMxc } from "../../../customisations/Media";
import { CardContext } from "../right_panel/context"; import { CardContext } from "../right_panel/context";
import UserIdentifierCustomisations from "../../../customisations/UserIdentifier"; import UserIdentifierCustomisations from "../../../customisations/UserIdentifier";
import { useRoomMemberProfile } from "../../../hooks/room/useRoomMemberProfile"; import { useRoomMemberProfile } from "../../../hooks/room/useRoomMemberProfile";
import { ViewUserPayload } from "../../../dispatcher/payloads/ViewUserPayload";
interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> { interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> {
member: RoomMember | null; member: RoomMember | null;
@ -33,13 +33,14 @@ interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" |
width: number; width: number;
height: number; height: number;
resizeMethod?: ResizeMethod; resizeMethod?: ResizeMethod;
/** Whether the onClick of the avatar should be overridden to dispatch `Action.ViewUser` */ // The onClick to give the avatar
onClick?: React.MouseEventHandler;
// Whether the onClick of the avatar should be overridden to dispatch `Action.ViewUser`
viewUserOnClick?: boolean; viewUserOnClick?: boolean;
pushUserOnClick?: boolean; pushUserOnClick?: boolean;
title?: string; title?: string;
style?: React.CSSProperties; style?: any;
/** true to deny `useOnlyCurrentProfiles` usage. Default false. */ forceHistorical?: boolean; // true to deny `useOnlyCurrentProfiles` usage. Default false.
forceHistorical?: boolean;
hideTitle?: boolean; hideTitle?: boolean;
} }
@ -76,8 +77,8 @@ export default function MemberAvatar({
if (!title) { if (!title) {
title = title =
UserIdentifierCustomisations.getDisplayUserIdentifier!(member.userId, { UserIdentifierCustomisations.getDisplayUserIdentifier(member?.userId ?? "", {
roomId: member.roomId, roomId: member?.roomId ?? "",
}) ?? fallbackUserId; }) ?? fallbackUserId;
} }
} }
@ -87,6 +88,7 @@ export default function MemberAvatar({
{...props} {...props}
width={width} width={width}
height={height} height={height}
resizeMethod={resizeMethod}
name={name ?? ""} name={name ?? ""}
title={hideTitle ? undefined : title} title={hideTitle ? undefined : title}
idName={member?.userId ?? fallbackUserId} idName={member?.userId ?? fallbackUserId}
@ -94,9 +96,9 @@ export default function MemberAvatar({
onClick={ onClick={
viewUserOnClick viewUserOnClick
? () => { ? () => {
dis.dispatch<ViewUserPayload>({ dis.dispatch({
action: Action.ViewUser, action: Action.ViewUser,
member: propsMember || undefined, member: propsMember,
push: card.isCard, push: card.isCard,
}); });
} }

View file

@ -109,8 +109,7 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
} }
private onRoomAvatarClick = (): void => { private onRoomAvatarClick = (): void => {
const avatarMxc = this.props.room?.getMxcAvatarUrl(); const avatarUrl = Avatar.avatarUrlForRoom(this.props.room, null, null, null);
const avatarUrl = avatarMxc ? mediaFromMxc(avatarMxc).srcHttp : null;
const params = { const params = {
src: avatarUrl, src: avatarUrl,
name: this.props.room.name, name: this.props.room.name,

View file

@ -0,0 +1,33 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { _t } from "../../../../languageHandler";
import BaseDialog from "../BaseDialog";
import { IDialogProps } from "../IDialogProps";
type PollHistoryDialogProps = Pick<IDialogProps, "onFinished"> & {
roomId: string;
};
export const PollHistoryDialog: React.FC<PollHistoryDialogProps> = ({ onFinished }) => {
return (
<BaseDialog title={_t("Polls history")} onFinished={onFinished}>
{/* @TODO(kerrya) to be implemented in PSG-906 */}
</BaseDialog>
);
};

View file

@ -28,6 +28,7 @@ import EventTileBubble from "./EventTileBubble";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import RoomContext from "../../../contexts/RoomContext"; import RoomContext from "../../../contexts/RoomContext";
import { useRoomState } from "../../../hooks/useRoomState"; import { useRoomState } from "../../../hooks/useRoomState";
import SettingsStore from "../../../settings/SettingsStore";
interface IProps { interface IProps {
/** The m.room.create MatrixEvent that this tile represents */ /** The m.room.create MatrixEvent that this tile represents */
@ -40,6 +41,8 @@ interface IProps {
* room. * room.
*/ */
export const RoomCreate: React.FC<IProps> = ({ mxEvent, timestamp }) => { export const RoomCreate: React.FC<IProps> = ({ mxEvent, timestamp }) => {
const msc3946ProcessDynamicPredecessor = SettingsStore.getValue("feature_dynamic_room_predecessors");
// Note: we ask the room for its predecessor here, instead of directly using // Note: we ask the room for its predecessor here, instead of directly using
// the information inside mxEvent. This allows us the flexibility later to // the information inside mxEvent. This allows us the flexibility later to
// use a different predecessor (e.g. through MSC3946) and still display it // use a different predecessor (e.g. through MSC3946) and still display it
@ -47,7 +50,10 @@ export const RoomCreate: React.FC<IProps> = ({ mxEvent, timestamp }) => {
const roomContext = useContext(RoomContext); const roomContext = useContext(RoomContext);
const predecessor = useRoomState( const predecessor = useRoomState(
roomContext.room, roomContext.room,
useCallback((state) => state.findPredecessor(), []), useCallback(
(state) => state.findPredecessor(msc3946ProcessDynamicPredecessor),
[msc3946ProcessDynamicPredecessor],
),
); );
const onLinkClicked = useCallback( const onLinkClicked = useCallback(

View file

@ -23,7 +23,7 @@ import { useIsEncrypted } from "../../../hooks/useIsEncrypted";
import BaseCard, { Group } from "./BaseCard"; import BaseCard, { Group } from "./BaseCard";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import RoomAvatar from "../avatars/RoomAvatar"; import RoomAvatar from "../avatars/RoomAvatar";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; import AccessibleButton, { ButtonEvent, IAccessibleButtonProps } from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher"; import defaultDispatcher from "../../../dispatcher/dispatcher";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
@ -51,6 +51,7 @@ import ExportDialog from "../dialogs/ExportDialog";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import PosthogTrackers from "../../../PosthogTrackers"; import PosthogTrackers from "../../../PosthogTrackers";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { PollHistoryDialog } from "../dialogs/polls/PollHistoryDialog";
interface IProps { interface IProps {
room: Room; room: Room;
@ -61,14 +62,15 @@ interface IAppsSectionProps {
room: Room; room: Room;
} }
interface IButtonProps { interface IButtonProps extends IAccessibleButtonProps {
className: string; className: string;
onClick(ev: ButtonEvent): void; onClick(ev: ButtonEvent): void;
} }
const Button: React.FC<IButtonProps> = ({ children, className, onClick }) => { const Button: React.FC<IButtonProps> = ({ children, className, onClick, ...props }) => {
return ( return (
<AccessibleButton <AccessibleButton
{...props}
className={classNames("mx_BaseCard_Button mx_RoomSummaryCard_Button", className)} className={classNames("mx_BaseCard_Button mx_RoomSummaryCard_Button", className)}
onClick={onClick} onClick={onClick}
> >
@ -281,6 +283,12 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
}); });
}; };
const onRoomPollHistoryClick = (): void => {
Modal.createDialog(PollHistoryDialog, {
roomId: room.roomId,
});
};
const isRoomEncrypted = useIsEncrypted(cli, room); const isRoomEncrypted = useIsEncrypted(cli, room);
const roomContext = useContext(RoomContext); const roomContext = useContext(RoomContext);
const e2eStatus = roomContext.e2eStatus; const e2eStatus = roomContext.e2eStatus;
@ -315,6 +323,8 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
const pinningEnabled = useFeatureEnabled("feature_pinning"); const pinningEnabled = useFeatureEnabled("feature_pinning");
const pinCount = usePinnedEvents(pinningEnabled && room)?.length; const pinCount = usePinnedEvents(pinningEnabled && room)?.length;
const isPollHistoryEnabled = useFeatureEnabled("feature_poll_history");
return ( return (
<BaseCard header={header} className="mx_RoomSummaryCard" onClose={onClose}> <BaseCard header={header} className="mx_RoomSummaryCard" onClose={onClose}>
<Group title={_t("About")} className="mx_RoomSummaryCard_aboutGroup"> <Group title={_t("About")} className="mx_RoomSummaryCard_aboutGroup">
@ -327,6 +337,11 @@ const RoomSummaryCard: React.FC<IProps> = ({ room, onClose }) => {
{_t("Files")} {_t("Files")}
</Button> </Button>
)} )}
{!isVideoRoom && isPollHistoryEnabled && (
<Button className="mx_RoomSummaryCard_icon_poll" onClick={onRoomPollHistoryClick}>
{_t("Polls history")}
</Button>
)}
{pinningEnabled && !isVideoRoom && ( {pinningEnabled && !isVideoRoom && (
<Button className="mx_RoomSummaryCard_icon_pins" onClick={onRoomPinsClick}> <Button className="mx_RoomSummaryCard_icon_pins" onClick={onRoomPinsClick}>
{_t("Pinned")} {_t("Pinned")}

View file

@ -28,11 +28,4 @@ export interface ViewUserPayload extends ActionPayload {
* should be shown (hide whichever relevant components). * should be shown (hide whichever relevant components).
*/ */
member?: RoomMember | User; member?: RoomMember | User;
/**
* Should this event be pushed as a card into the right panel?
*
* @see RightPanelStore#pushCard
*/
push?: boolean;
} }

View file

@ -1,5 +1,6 @@
/* /*
Copyright 2019, 2023 The Matrix.org Foundation C.I.C. Copyright 2019 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -300,9 +301,9 @@ export abstract class PillPart extends BasePart implements IPillPart {
} }
// helper method for subclasses // helper method for subclasses
protected setAvatarVars(node: HTMLElement, avatarBackground: string, initialLetter: string | undefined): void { protected setAvatarVars(node: HTMLElement, avatarUrl: string, initialLetter: string): void {
// const avatarBackground = `url('${avatarUrl}')`; const avatarBackground = `url('${avatarUrl}')`;
const avatarLetter = `'${initialLetter || ""}'`; const avatarLetter = `'${initialLetter}'`;
// check if the value is changing, // check if the value is changing,
// otherwise the avatars flicker on every keystroke while updating. // otherwise the avatars flicker on every keystroke while updating.
if (node.style.getPropertyValue("--avatar-background") !== avatarBackground) { if (node.style.getPropertyValue("--avatar-background") !== avatarBackground) {
@ -418,15 +419,13 @@ class RoomPillPart extends PillPart {
} }
protected setAvatar(node: HTMLElement): void { protected setAvatar(node: HTMLElement): void {
const avatarUrl = Avatar.avatarUrlForRoom(this.room, 16, 16, "crop"); let initialLetter = "";
if (avatarUrl) { let avatarUrl = Avatar.avatarUrlForRoom(this.room, 16, 16, "crop");
this.setAvatarVars(node, `url('${avatarUrl}')`, ""); if (!avatarUrl) {
return; initialLetter = Avatar.getInitialLetter(this.room?.name || this.resourceId);
avatarUrl = Avatar.defaultAvatarUrlForString(this.room?.roomId ?? this.resourceId);
} }
this.setAvatarVars(node, avatarUrl, initialLetter);
const initialLetter = Avatar.getInitialLetter(this.room?.name || this.resourceId);
const color = Avatar.getColorForString(this.room?.roomId ?? this.resourceId);
this.setAvatarVars(node, color, initialLetter);
} }
public get type(): IPillPart["type"] { public get type(): IPillPart["type"] {
@ -472,17 +471,14 @@ class UserPillPart extends PillPart {
if (!this.member) { if (!this.member) {
return; return;
} }
const avatar = Avatar.getMemberAvatar(this.member, 16, 16, "crop");
if (avatar) {
this.setAvatarVars(node, `url('${avatar}')`, "");
return;
}
const name = this.member.name || this.member.userId; const name = this.member.name || this.member.userId;
const initialLetter = Avatar.getInitialLetter(name); const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this.member.userId);
const color = Avatar.getColorForString(this.member.userId); const avatarUrl = Avatar.avatarUrlForMember(this.member, 16, 16, "crop");
this.setAvatarVars(node, color, initialLetter); let initialLetter = "";
if (avatarUrl === defaultAvatarUrl) {
initialLetter = Avatar.getInitialLetter(name);
}
this.setAvatarVars(node, avatarUrl, initialLetter);
} }
protected onClick = (): void => { protected onClick = (): void => {

View file

@ -934,7 +934,7 @@
"Keep discussions organised with threads.": "Keep discussions organised with threads.", "Keep discussions organised with threads.": "Keep discussions organised with threads.",
"Threads help keep conversations on-topic and easy to track. <a>Learn more</a>.": "Threads help keep conversations on-topic and easy to track. <a>Learn more</a>.", "Threads help keep conversations on-topic and easy to track. <a>Learn more</a>.": "Threads help keep conversations on-topic and easy to track. <a>Learn more</a>.",
"Rich text editor": "Rich text editor", "Rich text editor": "Rich text editor",
"Use rich text instead of Markdown in the message composer. Plain text mode coming soon.": "Use rich text instead of Markdown in the message composer. Plain text mode coming soon.", "Use rich text instead of Markdown in the message composer.": "Use rich text instead of Markdown in the message composer.",
"Render simple counters in room header": "Render simple counters in room header", "Render simple counters in room header": "Render simple counters in room header",
"New ways to ignore people": "New ways to ignore people", "New ways to ignore people": "New ways to ignore people",
"Currently experimental.": "Currently experimental.", "Currently experimental.": "Currently experimental.",
@ -948,6 +948,8 @@
"Use new room breadcrumbs": "Use new room breadcrumbs", "Use new room breadcrumbs": "Use new room breadcrumbs",
"Right panel stays open": "Right panel stays open", "Right panel stays open": "Right panel stays open",
"Defaults to room member list.": "Defaults to room member list.", "Defaults to room member list.": "Defaults to room member list.",
"Polls history": "Polls history",
"View a list of polls in a room. (Under active development)": "View a list of polls in a room. (Under active development)",
"Jump to date (adds /jumptodate and jump to date headers)": "Jump to date (adds /jumptodate and jump to date headers)", "Jump to date (adds /jumptodate and jump to date headers)": "Jump to date (adds /jumptodate and jump to date headers)",
"Send read receipts": "Send read receipts", "Send read receipts": "Send read receipts",
"Sliding Sync mode": "Sliding Sync mode", "Sliding Sync mode": "Sliding Sync mode",

View file

@ -291,7 +291,7 @@ export const SETTINGS: { [setting: string]: ISetting } = {
isFeature: true, isFeature: true,
labsGroup: LabGroup.Messaging, labsGroup: LabGroup.Messaging,
displayName: _td("Rich text editor"), displayName: _td("Rich text editor"),
description: _td("Use rich text instead of Markdown in the message composer. Plain text mode coming soon."), description: _td("Use rich text instead of Markdown in the message composer."),
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
default: false, default: false,
}, },
@ -382,6 +382,14 @@ export const SETTINGS: { [setting: string]: ISetting } = {
description: _td("Defaults to room member list."), description: _td("Defaults to room member list."),
default: false, default: false,
}, },
"feature_poll_history": {
isFeature: true,
labsGroup: LabGroup.Rooms,
supportedLevels: LEVELS_FEATURE,
displayName: _td("Polls history"),
description: _td("View a list of polls in a room. (Under active development)"),
default: false,
},
"feature_jump_to_date": { "feature_jump_to_date": {
// We purposely leave out `isFeature: true` so it doesn't show in Labs // We purposely leave out `isFeature: true` so it doesn't show in Labs
// by default. We will conditionally show it depending on whether we can // by default. We will conditionally show it depending on whether we can

View file

@ -18,6 +18,7 @@ import { MatrixClient } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { EventType } from "matrix-js-sdk/src/@types/event"; import { EventType } from "matrix-js-sdk/src/@types/event";
import { RoomState } from "matrix-js-sdk/src/matrix";
import SettingsStore from "../../settings/SettingsStore"; import SettingsStore from "../../settings/SettingsStore";
import { DefaultTagID, OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models"; import { DefaultTagID, OrderedDefaultTagIDs, RoomUpdateCause, TagID } from "./models";
@ -267,44 +268,55 @@ export class RoomListStoreClass extends AsyncStoreWithClient<IState> implements
} }
this.updateFn.trigger(); this.updateFn.trigger();
} else if (payload.action === "MatrixActions.Room.myMembership") { } else if (payload.action === "MatrixActions.Room.myMembership") {
const membershipPayload = <any>payload; // TODO: Type out the dispatcher types this.onDispatchMyMembership(<any>payload);
const oldMembership = getEffectiveMembership(membershipPayload.oldMembership); return;
const newMembership = getEffectiveMembership(membershipPayload.membership); }
if (oldMembership !== EffectiveMembership.Join && newMembership === EffectiveMembership.Join) { }
// If we're joining an upgraded room, we'll want to make sure we don't proliferate
// the dead room in the list.
const createEvent = membershipPayload.room.currentState.getStateEvents(EventType.RoomCreate, "");
if (createEvent && createEvent.getContent()["predecessor"]) {
const prevRoom = this.matrixClient.getRoom(createEvent.getContent()["predecessor"]["room_id"]);
if (prevRoom) {
const isSticky = this.algorithm.stickyRoom === prevRoom;
if (isSticky) {
this.algorithm.setStickyRoom(null);
}
// Note: we hit the algorithm instead of our handleRoomUpdate() function to /**
// avoid redundant updates. * Handle a MatrixActions.Room.myMembership event from the dispatcher.
this.algorithm.handleRoomUpdate(prevRoom, RoomUpdateCause.RoomRemoved); *
* Public for test.
*/
public async onDispatchMyMembership(membershipPayload: any): Promise<void> {
// TODO: Type out the dispatcher types so membershipPayload is not any
const oldMembership = getEffectiveMembership(membershipPayload.oldMembership);
const newMembership = getEffectiveMembership(membershipPayload.membership);
if (oldMembership !== EffectiveMembership.Join && newMembership === EffectiveMembership.Join) {
// If we're joining an upgraded room, we'll want to make sure we don't proliferate
// the dead room in the list.
const roomState: RoomState = membershipPayload.room.currentState;
const createEvent = roomState.getStateEvents(EventType.RoomCreate, "");
if (createEvent && createEvent.getContent()["predecessor"]) {
const prevRoom = this.matrixClient.getRoom(createEvent.getContent()["predecessor"]["room_id"]);
if (prevRoom) {
const isSticky = this.algorithm.stickyRoom === prevRoom;
if (isSticky) {
this.algorithm.setStickyRoom(null);
} }
// Note: we hit the algorithm instead of our handleRoomUpdate() function to
// avoid redundant updates.
this.algorithm.handleRoomUpdate(prevRoom, RoomUpdateCause.RoomRemoved);
} }
await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom);
this.updateFn.trigger();
return;
} }
if (oldMembership !== EffectiveMembership.Invite && newMembership === EffectiveMembership.Invite) { await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom);
await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom); this.updateFn.trigger();
this.updateFn.trigger(); return;
return; }
}
// If it's not a join, it's transitioning into a different list (possibly historical) if (oldMembership !== EffectiveMembership.Invite && newMembership === EffectiveMembership.Invite) {
if (oldMembership !== newMembership) { await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.NewRoom);
await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.PossibleTagChange); this.updateFn.trigger();
this.updateFn.trigger(); return;
return; }
}
// If it's not a join, it's transitioning into a different list (possibly historical)
if (oldMembership !== newMembership) {
await this.handleRoomUpdate(membershipPayload.room, RoomUpdateCause.PossibleTagChange);
this.updateFn.trigger();
return;
} }
} }

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,106 +15,33 @@ limitations under the License.
*/ */
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import { Room, RoomMember, RoomType, User } from "matrix-js-sdk/src/matrix"; import { Room, RoomMember, RoomType } from "matrix-js-sdk/src/matrix";
import { import { avatarUrlForRoom } from "../src/Avatar";
avatarUrlForMember, import { Media, mediaFromMxc } from "../src/customisations/Media";
avatarUrlForRoom,
avatarUrlForUser,
defaultAvatarUrlForString,
getColorForString,
getInitialLetter,
} from "../src/Avatar";
import { mediaFromMxc } from "../src/customisations/Media";
import DMRoomMap from "../src/utils/DMRoomMap"; import DMRoomMap from "../src/utils/DMRoomMap";
import { filterConsole, stubClient } from "./test-utils";
jest.mock("../src/customisations/Media", () => ({
mediaFromMxc: jest.fn(),
}));
const roomId = "!room:example.com"; const roomId = "!room:example.com";
const avatarUrl1 = "https://example.com/avatar1"; const avatarUrl1 = "https://example.com/avatar1";
const avatarUrl2 = "https://example.com/avatar2"; const avatarUrl2 = "https://example.com/avatar2";
describe("avatarUrlForMember", () => {
let member: RoomMember;
beforeEach(() => {
stubClient();
member = new RoomMember(roomId, "@user:example.com");
});
it("returns the member's url", () => {
const mxc = "mxc://example.com/a/b/c/d/avatar.gif";
jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue(mxc);
expect(avatarUrlForMember(member, 32, 32, "crop")).toBe(
mediaFromMxc(mxc).getThumbnailOfSourceHttp(32, 32, "crop"),
);
});
it("returns a default if the member has no avatar", () => {
jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue(undefined);
expect(avatarUrlForMember(member, 32, 32, "crop")).toMatch(/^data:/);
});
});
describe("avatarUrlForUser", () => {
let user: User;
beforeEach(() => {
stubClient();
user = new User("@user:example.com");
});
it("should return the user's avatar", () => {
const mxc = "mxc://example.com/a/b/c/d/avatar.gif";
user.avatarUrl = mxc;
expect(avatarUrlForUser(user, 64, 64, "scale")).toBe(
mediaFromMxc(mxc).getThumbnailOfSourceHttp(64, 64, "scale"),
);
});
it("should not provide a fallback", () => {
expect(avatarUrlForUser(user, 64, 64, "scale")).toBeNull();
});
});
describe("defaultAvatarUrlForString", () => {
it.each(["a", "abc", "abcde", "@".repeat(150)])("should return a value for %s", (s) => {
expect(defaultAvatarUrlForString(s)).not.toBe("");
});
});
describe("getColorForString", () => {
it.each(["a", "abc", "abcde", "@".repeat(150)])("should return a value for %s", (s) => {
expect(getColorForString(s)).toMatch(/^#\w+$/);
});
it("should return different values for different strings", () => {
expect(getColorForString("a")).not.toBe(getColorForString("b"));
});
});
describe("getInitialLetter", () => {
filterConsole("argument to `getInitialLetter` not supplied");
it.each(["a", "abc", "abcde", "@".repeat(150)])("should return a value for %s", (s) => {
expect(getInitialLetter(s)).not.toBe("");
});
it("should return undefined for empty strings", () => {
expect(getInitialLetter("")).toBeUndefined();
});
});
describe("avatarUrlForRoom", () => { describe("avatarUrlForRoom", () => {
let getThumbnailOfSourceHttp: jest.Mock;
let room: Room; let room: Room;
let roomMember: RoomMember; let roomMember: RoomMember;
let dmRoomMap: DMRoomMap; let dmRoomMap: DMRoomMap;
beforeEach(() => { beforeEach(() => {
stubClient(); getThumbnailOfSourceHttp = jest.fn();
mocked(mediaFromMxc).mockImplementation((): Media => {
return {
getThumbnailOfSourceHttp,
} as unknown as Media;
});
room = { room = {
roomId, roomId,
getMxcAvatarUrl: jest.fn(), getMxcAvatarUrl: jest.fn(),
@ -132,14 +59,14 @@ describe("avatarUrlForRoom", () => {
}); });
it("should return null for a null room", () => { it("should return null for a null room", () => {
expect(avatarUrlForRoom(undefined, 128, 128)).toBeNull(); expect(avatarUrlForRoom(null, 128, 128)).toBeNull();
}); });
it("should return the HTTP source if the room provides a MXC url", () => { it("should return the HTTP source if the room provides a MXC url", () => {
mocked(room.getMxcAvatarUrl).mockReturnValue(avatarUrl1); mocked(room.getMxcAvatarUrl).mockReturnValue(avatarUrl1);
expect(avatarUrlForRoom(room, 128, 256, "crop")).toBe( getThumbnailOfSourceHttp.mockReturnValue(avatarUrl2);
mediaFromMxc(avatarUrl1).getThumbnailOfSourceHttp(128, 256, "crop"), expect(avatarUrlForRoom(room, 128, 256, "crop")).toEqual(avatarUrl2);
); expect(getThumbnailOfSourceHttp).toHaveBeenCalledWith(128, 256, "crop");
}); });
it("should return null for a space room", () => { it("should return null for a space room", () => {
@ -156,7 +83,7 @@ describe("avatarUrlForRoom", () => {
it("should return null if there is no other member in the room", () => { it("should return null if there is no other member in the room", () => {
mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com"); mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com");
mocked(room.getAvatarFallbackMember).mockReturnValue(undefined); mocked(room.getAvatarFallbackMember).mockReturnValue(null);
expect(avatarUrlForRoom(room, 128, 128)).toBeNull(); expect(avatarUrlForRoom(room, 128, 128)).toBeNull();
}); });
@ -170,8 +97,8 @@ describe("avatarUrlForRoom", () => {
mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com"); mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com");
mocked(room.getAvatarFallbackMember).mockReturnValue(roomMember); mocked(room.getAvatarFallbackMember).mockReturnValue(roomMember);
mocked(roomMember.getMxcAvatarUrl).mockReturnValue(avatarUrl2); mocked(roomMember.getMxcAvatarUrl).mockReturnValue(avatarUrl2);
expect(avatarUrlForRoom(room, 128, 256, "crop")).toEqual( getThumbnailOfSourceHttp.mockReturnValue(avatarUrl2);
mediaFromMxc(avatarUrl2).getThumbnailOfSourceHttp(128, 256, "crop"), expect(avatarUrlForRoom(room, 128, 256, "crop")).toEqual(avatarUrl2);
); expect(getThumbnailOfSourceHttp).toHaveBeenCalledWith(128, 256, "crop");
}); });
}); });

View file

@ -20,16 +20,22 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1
<span <span
class="mx_BaseAvatar" class="mx_BaseAvatar"
role="presentation" role="presentation"
style="width: 24px; height: 24px;"
> >
<span <span
aria-hidden="true" aria-hidden="true"
class="mx_BaseAvatar_image mx_BaseAvatar_initial" class="mx_BaseAvatar_initial"
data-testid="avatar-img" style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;"
style="background-color: rgb(172, 59, 168); width: 24px; height: 24px; font-size: 15.600000000000001px; line-height: 24px;"
> >
U U
</span> </span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 24px; height: 24px;"
/>
</span> </span>
</div> </div>
</div> </div>
@ -113,16 +119,22 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
<span <span
class="mx_BaseAvatar" class="mx_BaseAvatar"
role="presentation" role="presentation"
style="width: 24px; height: 24px;"
> >
<span <span
aria-hidden="true" aria-hidden="true"
class="mx_BaseAvatar_image mx_BaseAvatar_initial" class="mx_BaseAvatar_initial"
data-testid="avatar-img" style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;"
style="background-color: rgb(172, 59, 168); width: 24px; height: 24px; font-size: 15.600000000000001px; line-height: 24px;"
> >
U U
</span> </span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 24px; height: 24px;"
/>
</span> </span>
</div> </div>
</div> </div>
@ -203,17 +215,23 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
aria-live="off" aria-live="off"
class="mx_AccessibleButton mx_BaseAvatar" class="mx_AccessibleButton mx_BaseAvatar"
role="button" role="button"
style="width: 52px; height: 52px;"
tabindex="0" tabindex="0"
> >
<span <span
aria-hidden="true" aria-hidden="true"
class="mx_BaseAvatar_image mx_BaseAvatar_initial" class="mx_BaseAvatar_initial"
data-testid="avatar-img" style="font-size: 33.800000000000004px; width: 52px; line-height: 52px;"
style="background-color: rgb(172, 59, 168); width: 52px; height: 52px; font-size: 33.800000000000004px; line-height: 52px;"
> >
U U
</span> </span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 52px; height: 52px;"
/>
</span> </span>
<h2> <h2>
@user:example.com @user:example.com
@ -296,16 +314,22 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
<span <span
class="mx_BaseAvatar" class="mx_BaseAvatar"
role="presentation" role="presentation"
style="width: 24px; height: 24px;"
> >
<span <span
aria-hidden="true" aria-hidden="true"
class="mx_BaseAvatar_image mx_BaseAvatar_initial" class="mx_BaseAvatar_initial"
data-testid="avatar-img" style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;"
style="background-color: rgb(172, 59, 168); width: 24px; height: 24px; font-size: 15.600000000000001px; line-height: 24px;"
> >
U U
</span> </span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 24px; height: 24px;"
/>
</span> </span>
</div> </div>
</div> </div>
@ -386,17 +410,23 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
aria-live="off" aria-live="off"
class="mx_AccessibleButton mx_BaseAvatar" class="mx_AccessibleButton mx_BaseAvatar"
role="button" role="button"
style="width: 52px; height: 52px;"
tabindex="0" tabindex="0"
> >
<span <span
aria-hidden="true" aria-hidden="true"
class="mx_BaseAvatar_image mx_BaseAvatar_initial" class="mx_BaseAvatar_initial"
data-testid="avatar-img" style="font-size: 33.800000000000004px; width: 52px; line-height: 52px;"
style="background-color: rgb(172, 59, 168); width: 52px; height: 52px; font-size: 33.800000000000004px; line-height: 52px;"
> >
U U
</span> </span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 52px; height: 52px;"
/>
</span> </span>
<h2> <h2>
@user:example.com @user:example.com
@ -551,16 +581,22 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
<span <span
class="mx_BaseAvatar" class="mx_BaseAvatar"
role="presentation" role="presentation"
style="width: 24px; height: 24px;"
> >
<span <span
aria-hidden="true" aria-hidden="true"
class="mx_BaseAvatar_image mx_BaseAvatar_initial" class="mx_BaseAvatar_initial"
data-testid="avatar-img" style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;"
style="background-color: rgb(172, 59, 168); width: 24px; height: 24px; font-size: 15.600000000000001px; line-height: 24px;"
> >
U U
</span> </span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 24px; height: 24px;"
/>
</span> </span>
</div> </div>
</div> </div>
@ -636,17 +672,23 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
aria-live="off" aria-live="off"
class="mx_AccessibleButton mx_BaseAvatar" class="mx_AccessibleButton mx_BaseAvatar"
role="button" role="button"
style="width: 52px; height: 52px;"
tabindex="0" tabindex="0"
> >
<span <span
aria-hidden="true" aria-hidden="true"
class="mx_BaseAvatar_image mx_BaseAvatar_initial" class="mx_BaseAvatar_initial"
data-testid="avatar-img" style="font-size: 33.800000000000004px; width: 52px; line-height: 52px;"
style="background-color: rgb(172, 59, 168); width: 52px; height: 52px; font-size: 33.800000000000004px; line-height: 52px;"
> >
U U
</span> </span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 52px; height: 52px;"
/>
</span> </span>
<h2> <h2>
@user:example.com @user:example.com

View file

@ -20,16 +20,22 @@ exports[`<UserMenu> when rendered should render as expected 1`] = `
<span <span
class="mx_BaseAvatar mx_UserMenu_userAvatar_BaseAvatar" class="mx_BaseAvatar mx_UserMenu_userAvatar_BaseAvatar"
role="presentation" role="presentation"
style="width: 32px; height: 32px;"
> >
<span <span
aria-hidden="true" aria-hidden="true"
class="mx_BaseAvatar_image mx_BaseAvatar_initial" class="mx_BaseAvatar_initial"
data-testid="avatar-img" style="font-size: 20.8px; width: 32px; line-height: 32px;"
style="background-color: rgb(54, 139, 214); width: 32px; height: 32px; font-size: 20.8px; line-height: 32px;"
> >
U U
</span> </span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 32px; height: 32px;"
/>
</span> </span>
</div> </div>
</div> </div>

View file

@ -1,201 +0,0 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { fireEvent, render } from "@testing-library/react";
import { ClientEvent, PendingEventOrdering } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import React from "react";
import { act } from "react-dom/test-utils";
import { SyncState } from "matrix-js-sdk/src/sync";
import type { MatrixClient } from "matrix-js-sdk/src/client";
import RoomContext from "../../../../src/contexts/RoomContext";
import { getRoomContext } from "../../../test-utils/room";
import { stubClient } from "../../../test-utils/test-utils";
import BaseAvatar from "../../../../src/components/views/avatars/BaseAvatar";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
type Props = React.ComponentPropsWithoutRef<typeof BaseAvatar>;
describe("<BaseAvatar />", () => {
let client: MatrixClient;
let room: Room;
let member: RoomMember;
function getComponent(props: Partial<Props>) {
return (
<MatrixClientContext.Provider value={client}>
<RoomContext.Provider value={getRoomContext(room, {})}>
<BaseAvatar name="" {...props} />
</RoomContext.Provider>
</MatrixClientContext.Provider>
);
}
function failLoadingImg(container: HTMLElement): void {
const img = container.querySelector<HTMLImageElement>("img")!;
expect(img).not.toBeNull();
act(() => {
fireEvent.error(img);
});
}
function emitReconnect(): void {
act(() => {
client.emit(ClientEvent.Sync, SyncState.Prepared, SyncState.Reconnecting);
});
}
beforeEach(() => {
client = stubClient();
room = new Room("!room:example.com", client, client.getUserId() ?? "", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
member = new RoomMember(room.roomId, "@bob:example.org");
jest.spyOn(room, "getMember").mockReturnValue(member);
});
it("renders with minimal properties", () => {
const { container } = render(getComponent({}));
expect(container.querySelector(".mx_BaseAvatar")).not.toBeNull();
});
it("matches snapshot (avatar)", () => {
const { container } = render(
getComponent({
name: "CoolUser22",
title: "Hover title",
url: "https://example.com/images/avatar.gif",
className: "mx_SomethingArbitrary",
}),
);
expect(container).toMatchSnapshot();
});
it("matches snapshot (avatar + click)", () => {
const { container } = render(
getComponent({
name: "CoolUser22",
title: "Hover title",
url: "https://example.com/images/avatar.gif",
className: "mx_SomethingArbitrary",
onClick: () => {},
}),
);
expect(container).toMatchSnapshot();
});
it("matches snapshot (no avatar)", () => {
const { container } = render(
getComponent({
name: "xX_Element_User_Xx",
title: ":kiss:",
defaultToInitialLetter: true,
className: "big-and-bold",
}),
);
expect(container).toMatchSnapshot();
});
it("matches snapshot (no avatar + click)", () => {
const { container } = render(
getComponent({
name: "xX_Element_User_Xx",
title: ":kiss:",
defaultToInitialLetter: true,
className: "big-and-bold",
onClick: () => {},
}),
);
expect(container).toMatchSnapshot();
});
it("uses fallback images", () => {
const images = [...Array(10)].map((_, i) => `https://example.com/images/${i}.webp`);
const { container } = render(
getComponent({
url: images[0],
urls: images.slice(1),
}),
);
for (const image of images) {
expect(container.querySelector("img")!.src).toBe(image);
failLoadingImg(container);
}
});
it("re-renders on reconnect", () => {
const primary = "https://example.com/image.jpeg";
const fallback = "https://example.com/fallback.png";
const { container } = render(
getComponent({
url: primary,
urls: [fallback],
}),
);
failLoadingImg(container);
expect(container.querySelector("img")!.src).toBe(fallback);
emitReconnect();
expect(container.querySelector("img")!.src).toBe(primary);
});
it("renders with an image", () => {
const url = "https://example.com/images/small/avatar.gif?size=realBig";
const { container } = render(getComponent({ url }));
const img = container.querySelector("img");
expect(img!.src).toBe(url);
});
it("renders the initial letter", () => {
const { container } = render(getComponent({ name: "Yellow", defaultToInitialLetter: true }));
const avatar = container.querySelector<HTMLSpanElement>(".mx_BaseAvatar_initial")!;
expect(avatar.innerHTML).toBe("Y");
});
it.each([{}, { name: "CoolUser22" }, { name: "XxElement_FanxX", defaultToInitialLetter: true }])(
"includes a click handler",
(props: Partial<Props>) => {
const onClick = jest.fn();
const { container } = render(
getComponent({
...props,
onClick,
}),
);
act(() => {
fireEvent.click(container.querySelector(".mx_BaseAvatar")!);
});
expect(onClick).toHaveBeenCalled();
},
);
});

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,25 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { fireEvent, getByTestId, render } from "@testing-library/react"; import { getByTestId, render, waitFor } from "@testing-library/react";
import { mocked } from "jest-mock";
import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member"; import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import React from "react"; import React from "react";
import { act } from "react-dom/test-utils";
import MemberAvatar from "../../../../src/components/views/avatars/MemberAvatar"; import MemberAvatar from "../../../../src/components/views/avatars/MemberAvatar";
import RoomContext from "../../../../src/contexts/RoomContext"; import RoomContext from "../../../../src/contexts/RoomContext";
import { mediaFromMxc } from "../../../../src/customisations/Media"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { ViewUserPayload } from "../../../../src/dispatcher/payloads/ViewUserPayload";
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
import SettingsStore from "../../../../src/settings/SettingsStore"; import SettingsStore from "../../../../src/settings/SettingsStore";
import { getRoomContext } from "../../../test-utils/room"; import { getRoomContext } from "../../../test-utils/room";
import { stubClient } from "../../../test-utils/test-utils"; import { stubClient } from "../../../test-utils/test-utils";
import { Action } from "../../../../src/dispatcher/actions";
type Props = React.ComponentPropsWithoutRef<typeof MemberAvatar>;
describe("MemberAvatar", () => { describe("MemberAvatar", () => {
const ROOM_ID = "roomId"; const ROOM_ID = "roomId";
@ -41,7 +35,7 @@ describe("MemberAvatar", () => {
let room: Room; let room: Room;
let member: RoomMember; let member: RoomMember;
function getComponent(props: Partial<Props>) { function getComponent(props) {
return ( return (
<RoomContext.Provider value={getRoomContext(room, {})}> <RoomContext.Provider value={getRoomContext(room, {})}>
<MemberAvatar member={null} width={35} height={35} {...props} /> <MemberAvatar member={null} width={35} height={35} {...props} />
@ -50,7 +44,10 @@ describe("MemberAvatar", () => {
} }
beforeEach(() => { beforeEach(() => {
mockClient = stubClient(); jest.clearAllMocks();
stubClient();
mockClient = mocked(MatrixClientPeg.get());
room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", { room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", {
pendingEventOrdering: PendingEventOrdering.Detached, pendingEventOrdering: PendingEventOrdering.Detached,
@ -58,77 +55,22 @@ describe("MemberAvatar", () => {
member = new RoomMember(ROOM_ID, "@bob:example.org"); member = new RoomMember(ROOM_ID, "@bob:example.org");
jest.spyOn(room, "getMember").mockReturnValue(member); jest.spyOn(room, "getMember").mockReturnValue(member);
});
it("supports 'null' members", () => {
const { container } = render(getComponent({ member: null }));
expect(container.querySelector("img")).not.toBeNull();
});
it("matches the snapshot", () => {
jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue("http://placekitten.com/400/400"); jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue("http://placekitten.com/400/400");
const { container } = render(
getComponent({
member,
fallbackUserId: "Fallback User ID",
title: "Hover title",
style: {
color: "pink",
},
}),
);
expect(container).toMatchSnapshot();
}); });
it("shows an avatar for useOnlyCurrentProfiles", () => { it("shows an avatar for useOnlyCurrentProfiles", async () => {
jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue("http://placekitten.com/400/400"); jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
return settingName === "useOnlyCurrentProfiles";
SettingsStore.setValue("useOnlyCurrentProfiles", null, SettingLevel.DEVICE, true); });
const { container } = render(getComponent({})); const { container } = render(getComponent({}));
const avatar = getByTestId<HTMLImageElement>(container, "avatar-img"); let avatar: HTMLElement;
expect(avatar).toBeInTheDocument(); await waitFor(() => {
expect(avatar.getAttribute("src")).not.toBe(""); avatar = getByTestId(container, "avatar-img");
}); expect(avatar).toBeInTheDocument();
it("uses the member's configured avatar", () => {
const mxcUrl = "mxc://example.com/avatars/user.tiff";
jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue(mxcUrl);
const { container } = render(getComponent({ member }));
const img = container.querySelector("img");
expect(img).not.toBeNull();
expect(img!.src).toBe(mediaFromMxc(mxcUrl).srcHttp);
});
it("uses a fallback when the member has no avatar", () => {
jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue(undefined);
const { container } = render(getComponent({ member }));
const img = container.querySelector(".mx_BaseAvatar_image");
expect(img).not.toBeNull();
});
it("dispatches on click", () => {
const { container } = render(getComponent({ member, viewUserOnClick: true }));
const spy = jest.spyOn(defaultDispatcher, "dispatch");
act(() => {
fireEvent.click(container.querySelector(".mx_BaseAvatar")!);
}); });
expect(spy).toHaveBeenCalled(); expect(avatar!.getAttribute("src")).not.toBe("");
const [payload] = spy.mock.lastCall!;
expect(payload).toStrictEqual<ViewUserPayload>({
action: Action.ViewUser,
member,
push: false,
});
}); });
}); });

View file

@ -39,7 +39,7 @@ describe("RoomAvatar", () => {
const dmRoomMap = new DMRoomMap(client); const dmRoomMap = new DMRoomMap(client);
jest.spyOn(dmRoomMap, "getUserIdForRoomId"); jest.spyOn(dmRoomMap, "getUserIdForRoomId");
jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap);
jest.spyOn(AvatarModule, "getColorForString"); jest.spyOn(AvatarModule, "defaultAvatarUrlForString");
}); });
afterAll(() => { afterAll(() => {
@ -48,14 +48,14 @@ describe("RoomAvatar", () => {
afterEach(() => { afterEach(() => {
mocked(DMRoomMap.shared().getUserIdForRoomId).mockReset(); mocked(DMRoomMap.shared().getUserIdForRoomId).mockReset();
mocked(AvatarModule.getColorForString).mockClear(); mocked(AvatarModule.defaultAvatarUrlForString).mockClear();
}); });
it("should render as expected for a Room", () => { it("should render as expected for a Room", () => {
const room = new Room("!room:example.com", client, client.getSafeUserId()); const room = new Room("!room:example.com", client, client.getSafeUserId());
room.name = "test room"; room.name = "test room";
expect(render(<RoomAvatar room={room} />).container).toMatchSnapshot(); expect(render(<RoomAvatar room={room} />).container).toMatchSnapshot();
expect(AvatarModule.getColorForString).toHaveBeenCalledWith(room.roomId); expect(AvatarModule.defaultAvatarUrlForString).toHaveBeenCalledWith(room.roomId);
}); });
it("should render as expected for a DM room", () => { it("should render as expected for a DM room", () => {
@ -64,7 +64,7 @@ describe("RoomAvatar", () => {
room.name = "DM room"; room.name = "DM room";
mocked(DMRoomMap.shared().getUserIdForRoomId).mockReturnValue(userId); mocked(DMRoomMap.shared().getUserIdForRoomId).mockReturnValue(userId);
expect(render(<RoomAvatar room={room} />).container).toMatchSnapshot(); expect(render(<RoomAvatar room={room} />).container).toMatchSnapshot();
expect(AvatarModule.getColorForString).toHaveBeenCalledWith(userId); expect(AvatarModule.defaultAvatarUrlForString).toHaveBeenCalledWith(userId);
}); });
it("should render as expected for a LocalRoom", () => { it("should render as expected for a LocalRoom", () => {
@ -73,6 +73,6 @@ describe("RoomAvatar", () => {
localRoom.name = "local test room"; localRoom.name = "local test room";
localRoom.targets.push(new DirectoryMember({ user_id: userId })); localRoom.targets.push(new DirectoryMember({ user_id: userId }));
expect(render(<RoomAvatar room={localRoom} />).container).toMatchSnapshot(); expect(render(<RoomAvatar room={localRoom} />).container).toMatchSnapshot();
expect(AvatarModule.getColorForString).toHaveBeenCalledWith(userId); expect(AvatarModule.defaultAvatarUrlForString).toHaveBeenCalledWith(userId);
}); });
}); });

View file

@ -1,72 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<BaseAvatar /> matches snapshot (avatar + click) 1`] = `
<div>
<img
alt="Avatar"
class="mx_AccessibleButton mx_BaseAvatar mx_BaseAvatar_image mx_SomethingArbitrary"
data-testid="avatar-img"
role="button"
src="https://example.com/images/avatar.gif"
style="width: 40px; height: 40px;"
tabindex="0"
title="Hover title"
/>
</div>
`;
exports[`<BaseAvatar /> matches snapshot (avatar) 1`] = `
<div>
<img
alt=""
class="mx_BaseAvatar mx_BaseAvatar_image mx_SomethingArbitrary"
data-testid="avatar-img"
src="https://example.com/images/avatar.gif"
style="width: 40px; height: 40px;"
title="Hover title"
/>
</div>
`;
exports[`<BaseAvatar /> matches snapshot (no avatar + click) 1`] = `
<div>
<span
aria-label="Avatar"
aria-live="off"
class="mx_AccessibleButton mx_BaseAvatar big-and-bold"
role="button"
style="width: 40px; height: 40px;"
tabindex="0"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_image mx_BaseAvatar_initial"
data-testid="avatar-img"
style="background-color: rgb(13, 189, 139); width: 40px; height: 40px; font-size: 26px; line-height: 40px;"
title=":kiss:"
>
X
</span>
</span>
</div>
`;
exports[`<BaseAvatar /> matches snapshot (no avatar) 1`] = `
<div>
<span
class="mx_BaseAvatar big-and-bold"
role="presentation"
style="width: 40px; height: 40px;"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_image mx_BaseAvatar_initial"
data-testid="avatar-img"
style="background-color: rgb(13, 189, 139); width: 40px; height: 40px; font-size: 26px; line-height: 40px;"
title=":kiss:"
>
X
</span>
</span>
</div>
`;

View file

@ -1,14 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MemberAvatar matches the snapshot 1`] = `
<div>
<img
alt=""
class="mx_BaseAvatar mx_BaseAvatar_image"
data-testid="avatar-img"
src="http://this.is.a.url//placekitten.com/400/400"
style="color: pink; width: 35px; height: 35px;"
title="Hover title"
/>
</div>
`;

View file

@ -5,16 +5,22 @@ exports[`RoomAvatar should render as expected for a DM room 1`] = `
<span <span
class="mx_BaseAvatar" class="mx_BaseAvatar"
role="presentation" role="presentation"
style="width: 36px; height: 36px;"
> >
<span <span
aria-hidden="true" aria-hidden="true"
class="mx_BaseAvatar_image mx_BaseAvatar_initial" class="mx_BaseAvatar_initial"
data-testid="avatar-img" style="font-size: 23.400000000000002px; width: 36px; line-height: 36px;"
style="background-color: rgb(13, 189, 139); width: 36px; height: 36px; font-size: 23.400000000000002px; line-height: 36px;"
> >
D D
</span> </span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 36px; height: 36px;"
/>
</span> </span>
</div> </div>
`; `;
@ -24,16 +30,22 @@ exports[`RoomAvatar should render as expected for a LocalRoom 1`] = `
<span <span
class="mx_BaseAvatar" class="mx_BaseAvatar"
role="presentation" role="presentation"
style="width: 36px; height: 36px;"
> >
<span <span
aria-hidden="true" aria-hidden="true"
class="mx_BaseAvatar_image mx_BaseAvatar_initial" class="mx_BaseAvatar_initial"
data-testid="avatar-img" style="font-size: 23.400000000000002px; width: 36px; line-height: 36px;"
style="background-color: rgb(172, 59, 168); width: 36px; height: 36px; font-size: 23.400000000000002px; line-height: 36px;"
> >
L L
</span> </span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 36px; height: 36px;"
/>
</span> </span>
</div> </div>
`; `;
@ -43,16 +55,22 @@ exports[`RoomAvatar should render as expected for a Room 1`] = `
<span <span
class="mx_BaseAvatar" class="mx_BaseAvatar"
role="presentation" role="presentation"
style="width: 36px; height: 36px;"
> >
<span <span
aria-hidden="true" aria-hidden="true"
class="mx_BaseAvatar_image mx_BaseAvatar_initial" class="mx_BaseAvatar_initial"
data-testid="avatar-img" style="font-size: 23.400000000000002px; width: 36px; line-height: 36px;"
style="background-color: rgb(172, 59, 168); width: 36px; height: 36px; font-size: 23.400000000000002px; line-height: 36px;"
> >
T T
</span> </span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 36px; height: 36px;"
/>
</span> </span>
</div> </div>
`; `;

View file

@ -13,17 +13,23 @@ exports[`<BeaconMarker /> renders marker when beacon has location 1`] = `
<span <span
class="mx_BaseAvatar" class="mx_BaseAvatar"
role="presentation" role="presentation"
style="width: 36px; height: 36px;"
> >
<span <span
aria-hidden="true" aria-hidden="true"
class="mx_BaseAvatar_image mx_BaseAvatar_initial" class="mx_BaseAvatar_initial"
data-testid="avatar-img" style="font-size: 23.400000000000002px; width: 36px; line-height: 36px;"
style="background-color: rgb(172, 59, 168); width: 36px; height: 36px; font-size: 23.400000000000002px; line-height: 36px;"
title="@alice:server"
> >
A A
</span> </span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 36px; height: 36px;"
title="@alice:server"
/>
</span> </span>
</div> </div>
</div> </div>

View file

@ -52,10 +52,35 @@ describe("<RoomCreate />", () => {
content: {}, content: {},
event_id: "$create", event_id: "$create",
}); });
const predecessorEvent = new MatrixEvent({
type: EventType.RoomPredecessor,
state_key: "",
sender: userId,
room_id: roomId,
content: {
predecessor_room_id: "old_room_id_from_predecessor",
},
event_id: "$create",
});
const predecessorEventWithEventId = new MatrixEvent({
type: EventType.RoomPredecessor,
state_key: "",
sender: userId,
room_id: roomId,
content: {
predecessor_room_id: "old_room_id_from_predecessor",
last_known_event_id: "tombstone_event_id_from_predecessor",
},
event_id: "$create",
});
stubClient(); stubClient();
const client = mocked(MatrixClientPeg.get()); const client = mocked(MatrixClientPeg.get());
const room = new Room(roomId, client, userId); const roomJustCreate = new Room(roomId, client, userId);
upsertRoomStateEvents(room, [createEvent]); upsertRoomStateEvents(roomJustCreate, [createEvent]);
const roomCreateAndPredecessor = new Room(roomId, client, userId);
upsertRoomStateEvents(roomCreateAndPredecessor, [createEvent, predecessorEvent]);
const roomCreateAndPredecessorWithEventId = new Room(roomId, client, userId);
upsertRoomStateEvents(roomCreateAndPredecessorWithEventId, [createEvent, predecessorEventWithEventId]);
const roomNoPredecessors = new Room(roomId, client, userId); const roomNoPredecessors = new Room(roomId, client, userId);
upsertRoomStateEvents(roomNoPredecessors, [createEventWithoutPredecessor]); upsertRoomStateEvents(roomNoPredecessors, [createEventWithoutPredecessor]);
@ -81,12 +106,12 @@ describe("<RoomCreate />", () => {
} }
it("Renders as expected", () => { it("Renders as expected", () => {
const roomCreate = renderRoomCreate(room); const roomCreate = renderRoomCreate(roomJustCreate);
expect(roomCreate.asFragment()).toMatchSnapshot(); expect(roomCreate.asFragment()).toMatchSnapshot();
}); });
it("Links to the old version of the room", () => { it("Links to the old version of the room", () => {
renderRoomCreate(room); renderRoomCreate(roomJustCreate);
expect(screen.getByText("Click here to see older messages.")).toHaveAttribute( expect(screen.getByText("Click here to see older messages.")).toHaveAttribute(
"href", "href",
"https://matrix.to/#/old_room_id/tombstone_event_id", "https://matrix.to/#/old_room_id/tombstone_event_id",
@ -99,7 +124,7 @@ describe("<RoomCreate />", () => {
}); });
it("Opens the old room on click", async () => { it("Opens the old room on click", async () => {
renderRoomCreate(room); renderRoomCreate(roomJustCreate);
const link = screen.getByText("Click here to see older messages."); const link = screen.getByText("Click here to see older messages.");
await act(() => userEvent.click(link)); await act(() => userEvent.click(link));
@ -115,4 +140,48 @@ describe("<RoomCreate />", () => {
}), }),
); );
}); });
it("Ignores m.predecessor if labs flag is off", () => {
renderRoomCreate(roomCreateAndPredecessor);
expect(screen.getByText("Click here to see older messages.")).toHaveAttribute(
"href",
"https://matrix.to/#/old_room_id/tombstone_event_id",
);
});
describe("When feature_dynamic_room_predecessors = true", () => {
beforeEach(() => {
jest.spyOn(SettingsStore, "getValue").mockImplementation(
(settingName) => settingName === "feature_dynamic_room_predecessors",
);
});
afterEach(() => {
jest.spyOn(SettingsStore, "getValue").mockReset();
});
it("Uses the create event if there is no m.predecessor", () => {
renderRoomCreate(roomJustCreate);
expect(screen.getByText("Click here to see older messages.")).toHaveAttribute(
"href",
"https://matrix.to/#/old_room_id/tombstone_event_id",
);
});
it("Uses m.predecessor when it's there", () => {
renderRoomCreate(roomCreateAndPredecessor);
expect(screen.getByText("Click here to see older messages.")).toHaveAttribute(
"href",
"https://matrix.to/#/old_room_id_from_predecessor",
);
});
it("Links to the event in the room if event ID is provided", () => {
renderRoomCreate(roomCreateAndPredecessorWithEventId);
expect(screen.getByText("Click here to see older messages.")).toHaveAttribute(
"href",
"https://matrix.to/#/old_room_id_from_predecessor/tombstone_event_id_from_predecessor",
);
});
});
}); });

View file

@ -0,0 +1,182 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { render, fireEvent } from "@testing-library/react";
import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import DMRoomMap from "../../../../src/utils/DMRoomMap";
import RoomSummaryCard from "../../../../src/components/views/right_panel/RoomSummaryCard";
import ShareDialog from "../../../../src/components/views/dialogs/ShareDialog";
import ExportDialog from "../../../../src/components/views/dialogs/ExportDialog";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
import * as settingsHooks from "../../../../src/hooks/useSettings";
import Modal from "../../../../src/Modal";
import RightPanelStore from "../../../../src/stores/right-panel/RightPanelStore";
import { RightPanelPhases } from "../../../../src/stores/right-panel/RightPanelStorePhases";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../test-utils";
import { PollHistoryDialog } from "../../../../src/components/views/dialogs/polls/PollHistoryDialog";
describe("<RoomSummaryCard />", () => {
const userId = "@alice:domain.org";
const mockClient = getMockClientWithEventEmitter({
...mockClientMethodsUser(userId),
isRoomEncrypted: jest.fn(),
getRoom: jest.fn(),
});
const roomId = "!room:domain.org";
const room = new Room(roomId, mockClient, userId);
const roomCreateEvent = new MatrixEvent({
type: "m.room.create",
room_id: roomId,
sender: userId,
content: {
creator: userId,
room_version: "5",
},
state_key: "",
});
room.currentState.setStateEvents([roomCreateEvent]);
const defaultProps = {
room,
onClose: jest.fn(),
};
const getComponent = (props = {}) =>
render(<RoomSummaryCard {...defaultProps} {...props} />, {
wrapper: ({ children }) => (
<MatrixClientContext.Provider value={mockClient}>{children}</MatrixClientContext.Provider>
),
});
const modalSpy = jest.spyOn(Modal, "createDialog");
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch");
const rightPanelCardSpy = jest.spyOn(RightPanelStore.instance, "pushCard");
const featureEnabledSpy = jest.spyOn(settingsHooks, "useFeatureEnabled");
beforeEach(() => {
jest.clearAllMocks();
DMRoomMap.makeShared();
mockClient.getRoom.mockReturnValue(room);
jest.spyOn(room, "isElementVideoRoom").mockRestore();
jest.spyOn(room, "isCallRoom").mockRestore();
featureEnabledSpy.mockReset().mockReturnValue(false);
});
it("renders the room summary", () => {
const { container } = getComponent();
expect(container).toMatchSnapshot();
});
it("opens room members list on button click", () => {
const { getByText } = getComponent();
fireEvent.click(getByText("People"));
expect(rightPanelCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.RoomMemberList }, true);
});
it("opens room file panel on button click", () => {
const { getByText } = getComponent();
fireEvent.click(getByText("Files"));
expect(rightPanelCardSpy).toHaveBeenCalledWith({ phase: RightPanelPhases.FilePanel }, true);
});
it("opens room export dialog on button click", () => {
const { getByText } = getComponent();
fireEvent.click(getByText("Export chat"));
expect(modalSpy).toHaveBeenCalledWith(ExportDialog, { room });
});
it("opens share room dialog on button click", () => {
const { getByText } = getComponent();
fireEvent.click(getByText("Share room"));
expect(modalSpy).toHaveBeenCalledWith(ShareDialog, { target: room });
});
it("opens room settings on button click", () => {
const { getByText } = getComponent();
fireEvent.click(getByText("Room settings"));
expect(dispatchSpy).toHaveBeenCalledWith({ action: "open_room_settings" });
});
describe("pinning", () => {
it("renders pins options when pinning feature is enabled", () => {
featureEnabledSpy.mockImplementation((feature) => feature === "feature_pinning");
const { getByText } = getComponent();
expect(getByText("Pinned")).toBeInTheDocument();
});
});
describe("poll history", () => {
it("renders poll history option when feature is enabled", () => {
featureEnabledSpy.mockImplementation((feature) => feature === "feature_poll_history");
const { getByText } = getComponent();
expect(getByText("Polls history")).toBeInTheDocument();
});
it("opens poll history dialog on button click", () => {
featureEnabledSpy.mockImplementation((feature) => feature === "feature_poll_history");
const { getByText } = getComponent();
fireEvent.click(getByText("Polls history"));
expect(modalSpy).toHaveBeenCalledWith(PollHistoryDialog, { roomId });
});
});
describe("video rooms", () => {
it("does not render irrelevant options for element video room", () => {
jest.spyOn(room, "isElementVideoRoom").mockReturnValue(true);
featureEnabledSpy.mockImplementation(
(feature) => feature === "feature_video_rooms" || feature === "feature_pinning",
);
const { queryByText } = getComponent();
// options not rendered
expect(queryByText("Files")).not.toBeInTheDocument();
expect(queryByText("Pinned")).not.toBeInTheDocument();
expect(queryByText("Export chat")).not.toBeInTheDocument();
});
it("does not render irrelevant options for element call room", () => {
jest.spyOn(room, "isCallRoom").mockReturnValue(true);
featureEnabledSpy.mockImplementation(
(feature) =>
feature === "feature_element_call_video_rooms" ||
feature === "feature_video_rooms" ||
feature === "feature_pinning",
);
const { queryByText } = getComponent();
// options not rendered
expect(queryByText("Files")).not.toBeInTheDocument();
expect(queryByText("Pinned")).not.toBeInTheDocument();
expect(queryByText("Export chat")).not.toBeInTheDocument();
});
});
});

View file

@ -0,0 +1,125 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<RoomSummaryCard /> renders the room summary 1`] = `
<div>
<div
class="mx_BaseCard mx_RoomSummaryCard"
>
<div
class="mx_BaseCard_header"
>
<div
class="mx_AccessibleButton mx_BaseCard_close"
data-testid="base-card-close-button"
role="button"
tabindex="0"
title="Close"
/>
<div
class="mx_RoomSummaryCard_avatar"
role="presentation"
>
<span
class="mx_BaseAvatar"
role="presentation"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 35.1px; width: 54px; line-height: 54px;"
>
!
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 54px; height: 54px;"
/>
</span>
<div
class="mx_TextWithTooltip_target mx_RoomSummaryCard_e2ee"
tabindex="0"
/>
</div>
<h2
title="!room:domain.org"
>
!room:domain.org
</h2>
<div
class="mx_RoomSummaryCard_alias"
title=""
/>
</div>
<div
class="mx_AutoHideScrollbar"
tabindex="-1"
>
<div
class="mx_BaseCard_Group mx_RoomSummaryCard_aboutGroup"
>
<h1>
About
</h1>
<div
class="mx_AccessibleButton mx_BaseCard_Button mx_RoomSummaryCard_Button mx_RoomSummaryCard_icon_people"
role="button"
tabindex="0"
>
People
<span
class="mx_BaseCard_Button_sublabel"
>
0
</span>
</div>
<div
class="mx_AccessibleButton mx_BaseCard_Button mx_RoomSummaryCard_Button mx_RoomSummaryCard_icon_files"
role="button"
tabindex="0"
>
Files
</div>
<div
class="mx_AccessibleButton mx_BaseCard_Button mx_RoomSummaryCard_Button mx_RoomSummaryCard_icon_export"
role="button"
tabindex="0"
>
Export chat
</div>
<div
class="mx_AccessibleButton mx_BaseCard_Button mx_RoomSummaryCard_Button mx_RoomSummaryCard_icon_share"
role="button"
tabindex="0"
>
Share room
</div>
<div
class="mx_AccessibleButton mx_BaseCard_Button mx_RoomSummaryCard_Button mx_RoomSummaryCard_icon_settings"
role="button"
tabindex="0"
>
Room settings
</div>
</div>
<div
class="mx_BaseCard_Group mx_RoomSummaryCard_appsGroup"
>
<h1>
Widgets
</h1>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
role="button"
tabindex="0"
>
Add widgets, bridges & bots
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -72,7 +72,7 @@ describe("RoomHeader (Enzyme)", () => {
// And there is no image avatar (because it's not set on this room) // And there is no image avatar (because it's not set on this room)
const image = findImg(rendered, ".mx_BaseAvatar_image"); const image = findImg(rendered, ".mx_BaseAvatar_image");
expect(image).toBeTruthy(); expect(image.prop("src")).toEqual("data:image/png;base64,00");
}); });
it("shows the room avatar in a room with 2 people", () => { it("shows the room avatar in a room with 2 people", () => {
@ -86,7 +86,7 @@ describe("RoomHeader (Enzyme)", () => {
// And there is no image avatar (because it's not set on this room) // And there is no image avatar (because it's not set on this room)
const image = findImg(rendered, ".mx_BaseAvatar_image"); const image = findImg(rendered, ".mx_BaseAvatar_image");
expect(image).toBeTruthy(); expect(image.prop("src")).toEqual("data:image/png;base64,00");
}); });
it("shows the room avatar in a room with >2 people", () => { it("shows the room avatar in a room with >2 people", () => {
@ -100,7 +100,7 @@ describe("RoomHeader (Enzyme)", () => {
// And there is no image avatar (because it's not set on this room) // And there is no image avatar (because it's not set on this room)
const image = findImg(rendered, ".mx_BaseAvatar_image"); const image = findImg(rendered, ".mx_BaseAvatar_image");
expect(image).toBeTruthy(); expect(image.prop("src")).toEqual("data:image/png;base64,00");
}); });
it("shows the room avatar in a DM with only ourselves", () => { it("shows the room avatar in a DM with only ourselves", () => {
@ -114,7 +114,7 @@ describe("RoomHeader (Enzyme)", () => {
// And there is no image avatar (because it's not set on this room) // And there is no image avatar (because it's not set on this room)
const image = findImg(rendered, ".mx_BaseAvatar_image"); const image = findImg(rendered, ".mx_BaseAvatar_image");
expect(image).toBeTruthy(); expect(image.prop("src")).toEqual("data:image/png;base64,00");
}); });
it("shows the user avatar in a DM with 2 people", () => { it("shows the user avatar in a DM with 2 people", () => {
@ -148,7 +148,7 @@ describe("RoomHeader (Enzyme)", () => {
// And there is no image avatar (because it's not set on this room) // And there is no image avatar (because it's not set on this room)
const image = findImg(rendered, ".mx_BaseAvatar_image"); const image = findImg(rendered, ".mx_BaseAvatar_image");
expect(image).toBeTruthy(); expect(image.prop("src")).toEqual("data:image/png;base64,00");
}); });
it("renders call buttons normally", () => { it("renders call buttons normally", () => {

View file

@ -161,16 +161,22 @@ exports[`<RoomPreviewBar /> with an invite without an invited email for a dm roo
<span <span
class="mx_BaseAvatar" class="mx_BaseAvatar"
role="presentation" role="presentation"
style="width: 36px; height: 36px;"
> >
<span <span
aria-hidden="true" aria-hidden="true"
class="mx_BaseAvatar_image mx_BaseAvatar_initial" class="mx_BaseAvatar_initial"
data-testid="avatar-img" style="font-size: 23.400000000000002px; width: 36px; line-height: 36px;"
style="background-color: rgb(172, 59, 168); width: 36px; height: 36px; font-size: 23.400000000000002px; line-height: 36px;"
> >
R R
</span> </span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 36px; height: 36px;"
/>
</span> </span>
</p> </p>
<p> <p>
@ -230,16 +236,22 @@ exports[`<RoomPreviewBar /> with an invite without an invited email for a non-dm
<span <span
class="mx_BaseAvatar" class="mx_BaseAvatar"
role="presentation" role="presentation"
style="width: 36px; height: 36px;"
> >
<span <span
aria-hidden="true" aria-hidden="true"
class="mx_BaseAvatar_image mx_BaseAvatar_initial" class="mx_BaseAvatar_initial"
data-testid="avatar-img" style="font-size: 23.400000000000002px; width: 36px; line-height: 36px;"
style="background-color: rgb(172, 59, 168); width: 36px; height: 36px; font-size: 23.400000000000002px; line-height: 36px;"
> >
R R
</span> </span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 36px; height: 36px;"
/>
</span> </span>
</p> </p>
<p> <p>

View file

@ -15,16 +15,22 @@ exports[`RoomTile should render the room 1`] = `
<span <span
class="mx_BaseAvatar" class="mx_BaseAvatar"
role="presentation" role="presentation"
style="width: 32px; height: 32px;"
> >
<span <span
aria-hidden="true" aria-hidden="true"
class="mx_BaseAvatar_image mx_BaseAvatar_initial" class="mx_BaseAvatar_initial"
data-testid="avatar-img" style="font-size: 20.8px; width: 32px; line-height: 32px;"
style="background-color: rgb(172, 59, 168); width: 32px; height: 32px; font-size: 20.8px; line-height: 32px;"
> >
! !
</span> </span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 32px; height: 32px;"
/>
</span> </span>
</div> </div>
<div <div

View file

@ -1,45 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RoomPillPart matches snapshot (avatar) 1`] = `
<span
class="mx_Pill mx_RoomPill"
contenteditable="false"
spellcheck="false"
style="--avatar-background: url('http://this.is.a.url/www.example.com/avatars/room1.jpeg'); --avatar-letter: '';"
>
!room:example.com
</span>
`;
exports[`RoomPillPart matches snapshot (no avatar) 1`] = `
<span
class="mx_Pill mx_RoomPill"
contenteditable="false"
spellcheck="false"
style="--avatar-background: #ac3ba8; --avatar-letter: '!';"
>
!room:example.com
</span>
`;
exports[`UserPillPart matches snapshot (avatar) 1`] = `
<span
class="mx_UserPill mx_Pill"
contenteditable="false"
spellcheck="false"
style="--avatar-background: url('http://this.is.a.url/www.example.com/avatar.png'); --avatar-letter: '';"
>
DisplayName
</span>
`;
exports[`UserPillPart matches snapshot (no avatar) 1`] = `
<span
class="mx_UserPill mx_Pill"
contenteditable="false"
spellcheck="false"
style="--avatar-background: #ac3ba8; --avatar-letter: 'U';"
>
DisplayName
</span>
`;

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2022 - 2023 The Matrix.org Foundation C.I.C. Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -14,11 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { MatrixClient, Room, RoomMember } from "matrix-js-sdk/src/matrix"; import { EmojiPart, PlainPart } from "../../src/editor/parts";
import { EmojiPart, PartCreator, PlainPart } from "../../src/editor/parts";
import DMRoomMap from "../../src/utils/DMRoomMap";
import { stubClient } from "../test-utils";
import { createPartCreator } from "./mock"; import { createPartCreator } from "./mock";
describe("editor/parts", () => { describe("editor/parts", () => {
@ -44,67 +40,3 @@ describe("editor/parts", () => {
expect(() => part.toDOMNode()).not.toThrow(); expect(() => part.toDOMNode()).not.toThrow();
}); });
}); });
describe("UserPillPart", () => {
const roomId = "!room:example.com";
let client: MatrixClient;
let room: Room;
let creator: PartCreator;
beforeEach(() => {
client = stubClient();
room = new Room(roomId, client, "@me:example.com");
creator = new PartCreator(room, client);
});
it("matches snapshot (no avatar)", () => {
jest.spyOn(room, "getMember").mockReturnValue(new RoomMember(room.roomId, "@user:example.com"));
const pill = creator.userPill("DisplayName", "@user:example.com");
const el = pill.toDOMNode();
expect(el).toMatchSnapshot();
});
it("matches snapshot (avatar)", () => {
const member = new RoomMember(room.roomId, "@user:example.com");
jest.spyOn(room, "getMember").mockReturnValue(member);
jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue("mxc://www.example.com/avatar.png");
const pill = creator.userPill("DisplayName", "@user:example.com");
const el = pill.toDOMNode();
expect(el).toMatchSnapshot();
});
});
describe("RoomPillPart", () => {
const roomId = "!room:example.com";
let client: jest.Mocked<MatrixClient>;
let room: Room;
let creator: PartCreator;
beforeEach(() => {
client = stubClient() as jest.Mocked<MatrixClient>;
DMRoomMap.makeShared();
room = new Room(roomId, client, "@me:example.com");
client.getRoom.mockReturnValue(room);
creator = new PartCreator(room, client);
});
it("matches snapshot (no avatar)", () => {
jest.spyOn(room, "getMxcAvatarUrl").mockReturnValue(null);
const pill = creator.roomPill("super-secret clubhouse");
const el = pill.toDOMNode();
expect(el).toMatchSnapshot();
});
it("matches snapshot (avatar)", () => {
jest.spyOn(room, "getMxcAvatarUrl").mockReturnValue("mxc://www.example.com/avatars/room1.jpeg");
const pill = creator.roomPill("cool chat club");
const el = pill.toDOMNode();
expect(el).toMatchSnapshot();
});
});

View file

@ -14,14 +14,55 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { EventType, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { MatrixDispatcher } from "../../../src/dispatcher/dispatcher";
import { ListAlgorithm, SortAlgorithm } from "../../../src/stores/room-list/algorithms/models"; import { ListAlgorithm, SortAlgorithm } from "../../../src/stores/room-list/algorithms/models";
import { OrderedDefaultTagIDs } from "../../../src/stores/room-list/models"; import { OrderedDefaultTagIDs, RoomUpdateCause } from "../../../src/stores/room-list/models";
import RoomListStore, { RoomListStoreClass } from "../../../src/stores/room-list/RoomListStore"; import RoomListStore, { RoomListStoreClass } from "../../../src/stores/room-list/RoomListStore";
import { stubClient } from "../../test-utils"; import { stubClient, upsertRoomStateEvents } from "../../test-utils";
describe("RoomListStore", () => { describe("RoomListStore", () => {
const client = stubClient();
const roomWithCreatePredecessorId = "!roomid:example.com";
const roomNoPredecessorId = "!roomnopreid:example.com";
const oldRoomId = "!oldroomid:example.com";
const userId = "@user:example.com";
const createWithPredecessor = new MatrixEvent({
type: EventType.RoomCreate,
sender: userId,
room_id: roomWithCreatePredecessorId,
content: {
predecessor: { room_id: oldRoomId, event_id: "tombstone_event_id" },
},
event_id: "$create",
state_key: "",
});
const createNoPredecessor = new MatrixEvent({
type: EventType.RoomCreate,
sender: userId,
room_id: roomWithCreatePredecessorId,
content: {},
event_id: "$create",
state_key: "",
});
const roomWithCreatePredecessor = new Room(roomWithCreatePredecessorId, client, userId, {});
upsertRoomStateEvents(roomWithCreatePredecessor, [createWithPredecessor]);
const roomNoPredecessor = new Room(roomNoPredecessorId, client, userId, {});
upsertRoomStateEvents(roomNoPredecessor, [createNoPredecessor]);
const oldRoom = new Room(oldRoomId, client, userId, {});
client.getRoom = jest.fn().mockImplementation((roomId) => {
switch (roomId) {
case roomWithCreatePredecessorId:
return roomWithCreatePredecessor;
case oldRoomId:
return oldRoom;
default:
return null;
}
});
beforeAll(async () => { beforeAll(async () => {
const client = stubClient();
await (RoomListStore.instance as RoomListStoreClass).makeReady(client); await (RoomListStore.instance as RoomListStoreClass).makeReady(client);
}); });
@ -32,4 +73,54 @@ describe("RoomListStore", () => {
it.each(OrderedDefaultTagIDs)("defaults to activity ordering for %s=", (tagId) => { it.each(OrderedDefaultTagIDs)("defaults to activity ordering for %s=", (tagId) => {
expect(RoomListStore.instance.getListOrder(tagId)).toBe(ListAlgorithm.Importance); expect(RoomListStore.instance.getListOrder(tagId)).toBe(ListAlgorithm.Importance);
}); });
function createStore(): { store: RoomListStoreClass; handleRoomUpdate: jest.Mock<any, any> } {
const fakeDispatcher = { register: jest.fn() } as unknown as MatrixDispatcher;
const store = new RoomListStoreClass(fakeDispatcher);
// @ts-ignore accessing private member to set client
store.readyStore.matrixClient = client;
const handleRoomUpdate = jest.fn();
// @ts-ignore accessing private member to mock it
store.algorithm.handleRoomUpdate = handleRoomUpdate;
return { store, handleRoomUpdate };
}
it("Removes old room if it finds a predecessor in the create event", () => {
// Given a store we can spy on
const { store, handleRoomUpdate } = createStore();
// When we tell it we joined a new room that has an old room as
// predecessor in the create event
const payload = {
oldMembership: "invite",
membership: "join",
room: roomWithCreatePredecessor,
};
store.onDispatchMyMembership(payload);
// Then the old room is removed
expect(handleRoomUpdate).toHaveBeenCalledWith(oldRoom, RoomUpdateCause.RoomRemoved);
// And the new room is added
expect(handleRoomUpdate).toHaveBeenCalledWith(roomWithCreatePredecessor, RoomUpdateCause.NewRoom);
});
it("Does not remove old room if there is no predecessor in the create event", () => {
// Given a store we can spy on
const { store, handleRoomUpdate } = createStore();
// When we tell it we joined a new room with no predecessor
const payload = {
oldMembership: "invite",
membership: "join",
room: roomNoPredecessor,
};
store.onDispatchMyMembership(payload);
// Then the new room is added
expect(handleRoomUpdate).toHaveBeenCalledWith(roomNoPredecessor, RoomUpdateCause.NewRoom);
// And no other updates happen
expect(handleRoomUpdate).toHaveBeenCalledTimes(1);
});
}); });

File diff suppressed because one or more lines are too long