Merge branch 'develop' of github.com:vector-im/element-web into langley/use_list_view_with_room_list

This commit is contained in:
David Langley 2025-08-05 19:09:28 +01:00
commit 36499ecdf1
40 changed files with 618 additions and 165 deletions

View File

@ -139,3 +139,16 @@ jobs:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
repository: vectorim/element-web
- name: Repository Dispatch
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3
if: github.event_name != 'pull_request'
with:
repository: element-hq/element-web-pro
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
event-type: image-built
# Stable way to determine the :version
client-payload: |-
{
"base-ref": "${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}"
}

View File

@ -1,3 +1,10 @@
Changes in [1.11.108](https://github.com/element-hq/element-web/releases/tag/v1.11.108) (2025-07-30)
====================================================================================================
## 🐛 Bug Fixes
* [Backport staging] Fix downloaded attachments not being decrypted ([#30434](https://github.com/element-hq/element-web/pull/30434)). Contributed by @RiotRobot.
Changes in [1.11.107](https://github.com/element-hq/element-web/releases/tag/v1.11.107) (2025-07-29)
====================================================================================================
## ✨ Features

View File

@ -1,6 +1,6 @@
{
"name": "element-web",
"version": "1.11.107",
"version": "1.11.108",
"description": "Element: the future of secure communication",
"author": "New Vector Ltd.",
"repository": {
@ -128,9 +128,9 @@
"jsrsasign": "^11.0.0",
"jszip": "^3.7.0",
"katex": "^0.16.0",
"linkify-react": "4.3.1",
"linkify-string": "4.3.1",
"linkifyjs": "4.3.1",
"linkify-react": "4.3.2",
"linkify-string": "4.3.2",
"linkifyjs": "4.3.2",
"lodash": "^4.17.21",
"maplibre-gl": "^5.0.0",
"matrix-encrypt-attachment": "^1.0.3",
@ -187,7 +187,7 @@
"@babel/runtime": "^7.12.5",
"@casualbot/jest-sonar-reporter": "2.2.7",
"@element-hq/element-call-embedded": "0.13.1",
"@element-hq/element-web-playwright-common": "^1.4.3",
"@element-hq/element-web-playwright-common": "^1.4.4",
"@peculiar/webcrypto": "^1.4.3",
"@playwright/test": "^1.50.1",
"@principalstudio/html-webpack-inject-preload": "^1.2.7",

View File

@ -19,6 +19,7 @@ const clickButtonReply = async (tile: Locator) => {
await tile.hover();
await tile.getByRole("button", { name: "Reply", exact: true }).click();
}).toPass();
await expect(tile.page().getByText("Replying", { exact: true })).toBeVisible();
};
test.describe("Audio player", { tag: ["@no-firefox", "@no-webkit"] }, () => {

View File

@ -35,8 +35,8 @@ test.describe("Header section of the room list", () => {
await expect(page.getByRole("menu")).toMatchScreenshot("room-list-header-compose-menu.png");
// New message should open the direct messages dialog
await page.getByRole("menuitem", { name: "New message" }).click();
// Start chat should open the direct messages dialog
await page.getByRole("menuitem", { name: "Start chat" }).click();
await expect(page.getByRole("heading", { name: "Direct Messages" })).toBeVisible();
await app.closeDialog();

View File

@ -11,6 +11,32 @@ import { Bot } from "../../pages/bot";
const ROOM_NAME = "Test room";
const NAME = "Alice";
async function setupRoomWithMembers(
app: any,
page: any,
homeserver: any,
roomName: string,
memberNames: string[],
): Promise<string> {
const visibility = await page.evaluate(() => (window as any).matrixcs.Visibility.Public);
const id = await app.client.createRoom({ name: roomName, visibility });
const bots: Bot[] = [];
for (let i = 0; i < memberNames.length; i++) {
const displayName = memberNames[i];
const bot = new Bot(page, homeserver, { displayName, startClient: false, autoAcceptInvites: false });
if (displayName === "Susan") {
await bot.prepareClient();
await app.client.inviteUser(id, bot.credentials?.userId);
} else {
await bot.joinRoom(id);
}
bots.push(bot);
}
return id;
}
test.use({
synapseConfig: {
presence: {
@ -25,17 +51,8 @@ test.use({
test.describe("Memberlist", () => {
test.beforeEach(async ({ app, user, page, homeserver }, testInfo) => {
testInfo.setTimeout(testInfo.timeout + 30_000);
const id = await app.client.createRoom({ name: ROOM_NAME });
const newBots: Bot[] = [];
const names = ["Bob", "Bob", "Susan"];
for (let i = 0; i < 3; i++) {
const displayName = names[i];
const autoAcceptInvites = displayName !== "Susan";
const bot = new Bot(page, homeserver, { displayName, startClient: true, autoAcceptInvites });
await bot.prepareClient();
await app.client.inviteUser(id, bot.credentials?.userId);
newBots.push(bot);
}
await setupRoomWithMembers(app, page, homeserver, ROOM_NAME, names);
});
test("Renders correctly", { tag: "@screenshot" }, async ({ page, app }) => {
@ -45,4 +62,37 @@ test.describe("Memberlist", () => {
await expect(memberlist.getByText("Invited")).toHaveCount(1);
await expect(page.locator(".mx_MemberListView")).toMatchScreenshot("with-four-members.png");
});
test("should handle scroll and click to view member profile", async ({ page, app, homeserver }) => {
// Create a room with many members to enable scrolling
const memberNames = Array.from({ length: 15 }, (_, i) => `Member${i.toString()}`);
await setupRoomWithMembers(app, page, homeserver, "Large Room", memberNames);
// Navigate to the room and open member list
await app.viewRoomByName("Large Room");
const memberlist = await app.toggleMemberlistPanel();
// Get the scrollable container
const memberListContainer = memberlist.locator(".mx_AutoHideScrollbar");
// Scroll down to the bottom of the member list
await app.scrollListToBottom(memberListContainer);
// Wait for the target member to be visible after scrolling
const targetName = "Member14";
const targetMember = memberlist.locator(".mx_MemberTileView_name").filter({ hasText: targetName });
await targetMember.waitFor({ state: "visible" });
// Verify Alice is not visible at this point
await expect(memberlist.locator(".mx_MemberTileView_name").filter({ hasText: "Alice" })).toHaveCount(0);
// Click on a member near the bottom of the list
await expect(targetMember).toBeVisible();
await targetMember.click();
// Verify that the user info screen is shown and hasn't scrolled back to top
await expect(page.locator(".mx_UserInfo")).toBeVisible();
await expect(page.locator(".mx_UserInfo_profile").getByText(targetName)).toBeVisible();
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
const TAG = "develop@sha256:8c2d9a93dd209a79d3e5e50cd18addfe52d80bea0ffe48a5d3e15836032eeb9d";
const TAG = "develop@sha256:2f6fff14ff23f356705abdbf2ed62c3dd6ca2103cef4ae813714ddc199bbd76a";
/**
* SynapseContainer which freezes the docker digest to stabilise tests,

View File

@ -141,6 +141,7 @@ import { type OpenForwardDialogPayload } from "../../dispatcher/payloads/OpenFor
import { ShareFormat, type SharePayload } from "../../dispatcher/payloads/SharePayload";
import Markdown from "../../Markdown";
import { sanitizeHtmlParams } from "../../Linkify";
import { isOnlyAdmin } from "../../utils/membership";
// legacy export
export { default as Views } from "../../Views";
@ -1255,29 +1256,22 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
const client = MatrixClientPeg.get();
if (client && roomToLeave) {
const plEvent = roomToLeave.currentState.getStateEvents(EventType.RoomPowerLevels, "");
const plContent = plEvent ? plEvent.getContent() : {};
const userLevels = plContent.users || {};
const currentUserLevel = userLevels[client.getUserId()!];
const userLevelValues = Object.values(userLevels);
if (userLevelValues.every((x) => typeof x === "number")) {
// If the user is the only user with highest power level
if (isOnlyAdmin(roomToLeave)) {
const userLevelValues = roomToLeave.getJoinedMembers().map((m) => m.powerLevel);
const maxUserLevel = Math.max(...(userLevelValues as number[]));
// If the user is the only user with highest power level
if (
maxUserLevel === currentUserLevel &&
userLevelValues.lastIndexOf(maxUserLevel) == userLevelValues.indexOf(maxUserLevel)
) {
const warning =
maxUserLevel >= 100
? _t("leave_room_dialog|room_leave_admin_warning")
: _t("leave_room_dialog|room_leave_mod_warning");
warnings.push(
<strong className="warning" key="last_admin_warning">
{" " /* Whitespace, otherwise the sentences get smashed together */}
{warning}
</strong>,
);
}
const warning =
maxUserLevel >= 100
? _t("leave_room_dialog|room_leave_admin_warning")
: _t("leave_room_dialog|room_leave_mod_warning");
warnings.push(
<strong className="warning" key="last_admin_warning">
{" " /* Whitespace, otherwise the sentences get smashed together */}
{warning}
</strong>,
);
}
}

View File

@ -36,7 +36,12 @@ export interface IListViewProps<Item, Context>
* @param context - The context object containing the focused key and any additional data
* @returns JSX element representing the rendered item
*/
getItemComponent: (index: number, item: Item, context: ListContext<Context>) => JSX.Element;
getItemComponent: (
index: number,
item: Item,
context: ListContext<Context>,
onFocus: (e: React.FocusEvent) => void,
) => JSX.Element;
/**
* Optional additional context data to pass to each rendered item.
@ -207,6 +212,20 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
virtuosoDomRef.current = element;
}, []);
const getItemComponentInternal = useCallback(
(index: number, item: Item, context: ListContext<Context>): JSX.Element => {
const onFocus = (e: React.FocusEvent): void => {
// If one of the item components has been focused directly, set the focused and tabIndex state
// and stop propagation so the ListViews onFocus doesn't also handle it.
const key = getItemKey(item);
setIsFocused(true);
setTabIndexKey(key);
e.stopPropagation();
};
return getItemComponent(index, item, context, onFocus);
},
[getItemComponent, getItemKey],
);
/**
* Handles focus events on the list.
* Sets the focused state and scrolls to the focused item if it is not currently visible.
@ -259,7 +278,7 @@ export function ListView<Item, Context = any>(props: IListViewProps<Item, Contex
data={props.items}
onFocus={onFocus}
onBlur={onBlur}
itemContent={props.getItemComponent}
itemContent={getItemComponentInternal}
{...virtuosoProps}
/>
);

View File

@ -14,6 +14,7 @@ import OnlineOrUnavailableIcon from "@vector-im/compound-design-tokens/assets/we
import OfflineIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-outline-8x8";
import BusyIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-strikethrough-8x8";
import classNames from "classnames";
import { Tooltip } from "@vector-im/compound-web";
import RoomAvatar from "./RoomAvatar";
import { AvatarBadgeDecoration, useRoomAvatarViewModel } from "../../viewmodels/avatars/RoomAvatarViewModel";
@ -37,6 +38,7 @@ export function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element {
if (!vm.badgeDecoration) return <RoomAvatar size="32px" room={room} />;
const icon = getAvatarDecoration(vm.badgeDecoration, vm.presence);
const label = getDecorationLabel(vm.badgeDecoration, vm.presence);
// Presence indicator and video/public icons don't have the same size
// We use different masks
@ -48,22 +50,15 @@ export function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element {
return (
<div className="mx_RoomAvatarView">
<RoomAvatar className={classNames("mx_RoomAvatarView_RoomAvatar", maskClass)} size="32px" room={room} />
{icon}
{label ? <Tooltip label={label}>{icon}</Tooltip> : icon}
</div>
);
}
type PresenceDecorationProps = {
/**
* The presence of the user in the DM room.
*/
presence: NonNullable<Presence>;
};
/**
* Component to display the presence of a user in a DM room.
* Get the decoration for the avatar based on the presence.
*/
function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element {
function getPresenceDecoration(presence: Presence): JSX.Element {
switch (presence) {
case Presence.Online:
return (
@ -72,7 +67,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
height="8px"
className="mx_RoomAvatarView_PresenceDecoration"
color="var(--cpd-color-icon-accent-primary)"
aria-label={_t("presence|online")}
aria-label={getPresenceLabel(presence)}
/>
);
case Presence.Away:
@ -82,7 +77,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
height="8px"
className="mx_RoomAvatarView_PresenceDecoration"
color="var(--cpd-color-icon-quaternary)"
aria-label={_t("presence|away")}
aria-label={getPresenceLabel(presence)}
/>
);
case Presence.Offline:
@ -92,7 +87,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
height="8px"
className="mx_RoomAvatarView_PresenceDecoration"
color="var(--cpd-color-icon-tertiary)"
aria-label={_t("presence|offline")}
aria-label={getPresenceLabel(presence)}
/>
);
case Presence.Busy:
@ -102,7 +97,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
height="8px"
className="mx_RoomAvatarView_PresenceDecoration"
color="var(--cpd-color-icon-tertiary)"
aria-label={_t("presence|busy")}
aria-label={getPresenceLabel(presence)}
/>
);
}
@ -116,7 +111,7 @@ function getAvatarDecoration(decoration: AvatarBadgeDecoration, presence: Presen
height="16px"
className="mx_RoomAvatarView_icon"
color="var(--cpd-color-icon-tertiary)"
aria-label={_t("room|room_is_low_priority")}
aria-label={getDecorationLabel(decoration, presence)}
/>
);
} else if (decoration === AvatarBadgeDecoration.VideoRoom) {
@ -126,7 +121,7 @@ function getAvatarDecoration(decoration: AvatarBadgeDecoration, presence: Presen
height="16px"
className="mx_RoomAvatarView_icon"
color="var(--cpd-color-icon-tertiary)"
aria-label={_t("room|video_room")}
aria-label={getDecorationLabel(decoration, presence)}
/>
);
} else if (decoration === AvatarBadgeDecoration.PublicRoom) {
@ -136,10 +131,44 @@ function getAvatarDecoration(decoration: AvatarBadgeDecoration, presence: Presen
height="16px"
className="mx_RoomAvatarView_icon"
color="var(--cpd-color-icon-info-primary)"
aria-label={_t("room|header|room_is_public")}
aria-label={getDecorationLabel(decoration, presence)}
/>
);
} else if (decoration === AvatarBadgeDecoration.Presence) {
return <PresenceDecoration presence={presence!} />;
return getPresenceDecoration(presence!);
}
}
/**
* Get the label for the avatar decoration.
* This is used for the tooltip and a11y label.
*/
function getDecorationLabel(decoration: AvatarBadgeDecoration, presence: Presence | null): string | undefined {
switch (decoration) {
case AvatarBadgeDecoration.LowPriority:
return _t("room|room_is_low_priority");
case AvatarBadgeDecoration.VideoRoom:
return _t("room|video_room");
case AvatarBadgeDecoration.PublicRoom:
return _t("room|header|room_is_public");
case AvatarBadgeDecoration.Presence:
return getPresenceLabel(presence!);
}
}
/**
* Get the label for the presence.
* This is used for the tooltip and a11y label.
*/
function getPresenceLabel(presence: Presence): string {
switch (presence) {
case Presence.Online:
return _t("presence|online");
case Presence.Away:
return _t("presence|away");
case Presence.Offline:
return _t("presence|offline");
case Presence.Busy:
return _t("presence|busy");
}
}

View File

@ -15,23 +15,13 @@ import BaseDialog from "../dialogs/BaseDialog";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import SpaceChildrenPicker from "../spaces/SpaceChildrenPicker";
import { filterBoolean } from "../../../utils/arrays";
import { isOnlyAdmin } from "../../../utils/membership";
interface IProps {
space: Room;
onFinished(leave: boolean, rooms?: Room[]): void;
}
const isOnlyAdmin = (room: Room): boolean => {
const userId = room.client.getSafeUserId();
if (room.getMember(userId)?.powerLevelNorm !== 100) {
return false; // user is not an admin
}
return room.getJoinedMembers().every((member) => {
// return true if every other member has a lower power level (we are highest)
return member.userId === userId || member.powerLevelNorm < 100;
});
};
const LeaveSpaceDialog: React.FC<IProps> = ({ space, onFinished }) => {
const spaceChildren = useMemo(() => {
const roomSet = new Set(SpaceStore.instance.getSpaceFilteredRoomIds(space.roomId));

View File

@ -26,6 +26,12 @@ interface IProps {
mediaEventHelperGet: () => MediaEventHelper | undefined;
}
function useButtonTitle(loading: boolean, isEncrypted: boolean): string {
if (!loading) return _t("action|download");
return isEncrypted ? _t("timeline|download_action_decrypting") : _t("timeline|download_action_downloading");
}
export default function DownloadActionButton({ mxEvent, mediaEventHelperGet }: IProps): ReactElement | null {
const mediaEventHelper = useMemo(() => mediaEventHelperGet(), [mediaEventHelperGet]);
const downloadUrl = mediaEventHelper?.media.srcHttp ?? "";
@ -33,6 +39,8 @@ export default function DownloadActionButton({ mxEvent, mediaEventHelperGet }: I
const { download, loading, canDownload } = useDownloadMedia(downloadUrl, fileName, mxEvent);
const buttonTitle = useButtonTitle(loading, mediaEventHelper?.media.isEncrypted ?? false);
if (!canDownload) return null;
const spinner = loading ? <Spinner w={18} h={18} /> : undefined;
@ -45,7 +53,7 @@ export default function DownloadActionButton({ mxEvent, mediaEventHelperGet }: I
return (
<RovingAccessibleButton
className={classes}
title={loading ? _t("timeline|download_action_downloading") : _t("action|download")}
title={buttonTitle}
onClick={download}
disabled={loading}
placement="left"

View File

@ -41,7 +41,12 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
}, []);
const getItemComponent = useCallback(
(index: number, item: MemberWithSeparator, context: ListContext<any>): JSX.Element => {
(
index: number,
item: MemberWithSeparator,
context: ListContext<any>,
onFocus: (e: React.FocusEvent) => void,
): JSX.Element => {
const itemKey = getItemKey(item);
const isRovingItem = itemKey === context.tabIndexKey;
const focused = isRovingItem && context.focused;
@ -56,6 +61,7 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
tabIndex={isRovingItem ? 0 : -1}
index={index}
memberCount={memberCount}
onFocus={onFocus}
/>
);
} else {
@ -66,6 +72,7 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
tabIndex={isRovingItem ? 0 : -1}
memberIndex={index - 1} // Adjust as invites are below the separator
memberCount={memberCount}
onFocus={onFocus}
/>
);
}
@ -73,19 +80,6 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
[isPresenceEnabled, getItemKey, memberCount],
);
const handleSelectItem = useCallback(
(item: MemberWithSeparator): void => {
if (item !== SEPARATOR) {
if (item.member) {
onClickMember(item.member);
} else {
onClickMember(item.threePidInvite);
}
}
},
[onClickMember],
);
const isItemFocusable = useCallback((item: MemberWithSeparator): boolean => {
return item !== SEPARATOR;
}, []);

View File

@ -24,6 +24,7 @@ interface IProps {
showPresence?: boolean;
focused?: boolean;
tabIndex?: number;
onFocus: (e: React.FocusEvent) => void;
}
export function RoomMemberTileView(props: IProps): JSX.Element {
@ -59,6 +60,7 @@ export function RoomMemberTileView(props: IProps): JSX.Element {
return (
<MemberTileView
onClick={vm.onClick}
onFocus={props.onFocus}
avatarJsx={av}
presenceJsx={presenceJSX}
nameJsx={nameJSX}

View File

@ -19,6 +19,7 @@ interface Props {
memberCount: number;
focused?: boolean;
tabIndex?: number;
onFocus: (e: React.FocusEvent) => void;
}
export function ThreePidInviteTileView(props: Props): JSX.Element {
@ -39,6 +40,7 @@ export function ThreePidInviteTileView(props: Props): JSX.Element {
iconJsx={iconJsx}
focused={props.focused}
tabIndex={props.tabIndex}
onFocus={props.onFocus}
/>
);
}

View File

@ -13,6 +13,7 @@ interface Props {
avatarJsx: JSX.Element;
nameJsx: JSX.Element | string;
onClick: () => void;
onFocus: (e: React.FocusEvent) => void;
memberIndex: number;
memberCount: number;
ariaLabel?: string;
@ -41,6 +42,7 @@ export function MemberTileView(props: Props): JSX.Element {
ref={ref}
className="mx_MemberTileView"
onClick={props.onClick}
onFocus={props.onFocus}
aria-label={props?.ariaLabel}
tabIndex={props.tabIndex}
role="option"

View File

@ -7,7 +7,7 @@
import React, { type JSX, type PropsWithChildren } from "react";
import { Button } from "@vector-im/compound-web";
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room";
import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
@ -148,8 +148,8 @@ function DefaultPlaceholder({ vm }: DefaultPlaceholderProps): JSX.Element {
direction="column"
gap="var(--cpd-space-4x)"
>
<Button size="sm" kind="secondary" Icon={UserAddIcon} onClick={vm.createChatRoom}>
{_t("action|new_message")}
<Button size="sm" kind="secondary" Icon={ChatIcon} onClick={vm.createChatRoom}>
{_t("action|start_chat")}
</Button>
{vm.canCreateRoom && (
<Button size="sm" kind="secondary" Icon={RoomIcon} onClick={vm.createRoom}>

View File

@ -14,6 +14,7 @@ import HomeIcon from "@vector-im/compound-design-tokens/assets/web/icons/home";
import PreferencesIcon from "@vector-im/compound-design-tokens/assets/web/icons/preferences";
import SettingsIcon from "@vector-im/compound-design-tokens/assets/web/icons/settings";
import VideoCallIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call";
import ChatIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat";
import { _t } from "../../../../languageHandler";
import { Flex } from "../../../../shared-components/utils/Flex";
@ -49,7 +50,7 @@ export function RoomListHeaderView(): JSX.Element {
{vm.displayComposeMenu ? (
<ComposeMenu vm={vm} />
) : (
<IconButton aria-label={_t("action|new_message")} onClick={(e) => vm.createChatRoom(e.nativeEvent)}>
<IconButton aria-label={_t("action|start_chat")} onClick={(e) => vm.createChatRoom(e.nativeEvent)}>
<ComposeIcon color="var(--cpd-color-icon-secondary)" />
</IconButton>
)}
@ -143,12 +144,7 @@ function ComposeMenu({ vm }: ComposeMenuProps): JSX.Element {
</IconButton>
}
>
<MenuItem
Icon={UserAddIcon}
label={_t("action|new_message")}
onSelect={vm.createChatRoom}
hideChevron={true}
/>
<MenuItem Icon={ChatIcon} label={_t("action|start_chat")} onSelect={vm.createChatRoom} hideChevron={true} />
{vm.canCreateRoom && (
<MenuItem Icon={RoomIcon} label={_t("action|new_room")} onSelect={vm.createRoom} hideChevron={true} />
)}

View File

@ -93,7 +93,6 @@
"maximise": "Maximise",
"mention": "Mention",
"minimise": "Minimise",
"new_message": "New message",
"new_room": "New room",
"new_video_room": "New video room",
"next": "Next",
@ -1534,6 +1533,9 @@
"render_reaction_images_description": "Sometimes referred to as \"custom emojis\".",
"report_to_moderators": "Report to moderators",
"report_to_moderators_description": "In rooms that support moderation, the “Report” button will let you report abuse to room moderators.",
"share_history_on_invite": "Share encrypted history with new members",
"share_history_on_invite_description": "When inviting a user to an encrypted room that has history visibility set to \"shared\", share encrypted history with that user, and accept encrypted history when you are invited to such a room.",
"share_history_on_invite_warning": "This feature is EXPERIMENTAL and not all security precautions are implemented. Do not enable on production accounts.",
"sliding_sync": "Sliding Sync mode",
"sliding_sync_description": "Under active development, cannot be disabled.",
"sliding_sync_disabled_notice": "Log out and back in to disable",
@ -3373,6 +3375,7 @@
"unable_to_decrypt": "Unable to decrypt message"
},
"disambiguated_profile": "%(displayName)s (%(matrixId)s)",
"download_action_decrypting": "Decrypting",
"download_action_downloading": "Downloading",
"download_failed": "Download failed",
"download_failed_description": "An error occurred while downloading this file",

View File

@ -669,12 +669,12 @@ export class ElementCall extends Call {
// Splice together the Element Call URL for this call
const params = new URLSearchParams({
embed: "true", // We're embedding EC within another application
confineToRoom: "true", // Only show the call interface for the configured room
// Template variables are used, so that this can be configured using the widget data.
skipLobby: "$skipLobby", // Skip the lobby in case we show a lobby component of our own.
returnToLobby: "$returnToLobby", // Returns to the lobby (instead of blank screen) when the call ends. (For video rooms)
perParticipantE2EE: "$perParticipantE2EE",
hideHeader: "true", // Hide the header since our room header is enough
header: "none", // Hide the header since our room header is enough
userId: client.getUserId()!,
deviceId: client.getDeviceId()!,
roomId: roomId,

View File

@ -205,6 +205,7 @@ export interface Settings {
"feature_mjolnir": IFeature;
"feature_custom_themes": IFeature;
"feature_exclude_insecure_devices": IFeature;
"feature_share_history_on_invite": IFeature;
"feature_html_topic": IFeature;
"feature_bridge_state": IFeature;
"feature_jump_to_date": IFeature;
@ -503,6 +504,29 @@ export const SETTINGS: Settings = {
supportedLevelsAreOrdered: true,
default: false,
},
"feature_share_history_on_invite": {
isFeature: true,
labsGroup: LabGroup.Encryption,
displayName: _td("labs|share_history_on_invite"),
description: () => (
<>
{_t("labs|share_history_on_invite_description")}
<div className="mx_SettingsFlag_microcopy">
{_t(
"settings|warning",
{},
{
w: (sub) => <span className="mx_SettingsTab_microcopy_warning">{sub}</span>,
description: _t("labs|share_history_on_invite_warning"),
},
)}
</div>
</>
),
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED,
supportedLevelsAreOrdered: true,
default: false,
},
"useOnlyCurrentProfiles": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td("settings|disable_historical_profile"),

View File

@ -10,7 +10,7 @@ Please see LICENSE files in the repository root for full details.
import React, { type ReactNode } from "react";
import * as utils from "matrix-js-sdk/src/utils";
import { MatrixError, JoinRule, type Room, type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { MatrixError, JoinRule, type Room, type MatrixEvent, type IJoinRoomOpts } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
import { type ViewRoom as ViewRoomEvent } from "@matrix-org/analytics-events/types/typescript/ViewRoom";
@ -512,15 +512,19 @@ export class RoomViewStore extends EventEmitter {
// take a copy of roomAlias & roomId as they may change by the time the join is complete
const { roomAlias, roomId = payload.roomId } = this.state;
const address = roomAlias || roomId!;
const viaServers = this.state.viaServers || [];
const joinOpts: IJoinRoomOpts = {
viaServers: this.state.viaServers || [],
...(payload.opts ?? {}),
};
if (SettingsStore.getValue("feature_share_history_on_invite")) {
joinOpts.acceptSharedHistory = true;
}
try {
const cli = MatrixClientPeg.safeGet();
await retry<Room, MatrixError>(
() =>
cli.joinRoom(address, {
viaServers,
...(payload.opts || {}),
}),
() => cli.joinRoom(address, joinOpts),
NUM_JOIN_RETRY,
(err) => {
// if we received a Gateway timeout or Cloudflare timeout then retry

View File

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { MatrixError, type MatrixClient, EventType, type EmptyObject } from "matrix-js-sdk/src/matrix";
import { MatrixError, type MatrixClient, EventType, type EmptyObject, type InviteOpts } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { logger } from "matrix-js-sdk/src/logger";
@ -183,7 +183,11 @@ export default class MultiInviter {
}
}
return this.matrixClient.invite(roomId, addr, this.reason);
const opts: InviteOpts = {};
if (this.reason !== undefined) opts.reason = this.reason;
if (SettingsStore.getValue("feature_share_history_on_invite")) opts.shareEncryptedHistory = true;
return this.matrixClient.invite(roomId, addr, opts);
} else {
throw new Error("Unsupported address");
}

View File

@ -131,3 +131,23 @@ export async function waitForMember(
client.removeListener(RoomStateEvent.NewMember, handler);
});
}
/**
* Check if the user is the only joined admin in the room
* This function will *not* cause lazy loading of room members, so if these should be included then
* the caller needs to make sure members have been loaded.
* @param room The room to check if the user is the only admin.
* @returns True if the user is the only user with the highest power level, false otherwise
*/
export function isOnlyAdmin(room: Room): boolean {
const currentUserLevel = room.getMember(room.client.getSafeUserId())?.powerLevel;
const userLevelValues = room.getJoinedMembers().map((m) => m.powerLevel);
const maxUserLevel = Math.max(...userLevelValues.filter((x) => typeof x === "number"));
// If the user is the only user with highest power level
return (
maxUserLevel === currentUserLevel &&
userLevelValues.lastIndexOf(maxUserLevel) == userLevelValues.indexOf(maxUserLevel)
);
}

View File

@ -270,11 +270,17 @@ describe("<MatrixChat />", () => {
// (must be sync otherwise the next test will start before it happens)
act(() => defaultDispatcher.dispatch({ action: Action.OnLoggedOut }, true));
// that will cause the Login to kick off an update in the background, which we need to allow to finish within
// an `act` to avoid warnings
await flushPromises();
localStorage.clear();
// This is a massive hack, but ...
//
// A lot of these tests end up completing while the login flow is still proceeding. So then, we start the next
// test while stuff is still ongoing from the previous test, which messes up the current test (by changing
// localStorage or opening modals, or whatever).
//
// There is no obvious event we could wait for which indicates that everything has completed, since each test
// does something different. Instead...
await act(() => sleep(200));
});
resetJsDomAfterEach();
@ -685,6 +691,8 @@ describe("<MatrixChat />", () => {
jest.spyOn(spaceRoom, "isSpaceRoom").mockReturnValue(true);
jest.spyOn(ReleaseAnnouncementStore.instance, "getReleaseAnnouncement").mockReturnValue(null);
(room as any).client = mockClient;
(spaceRoom as any).client = mockClient;
});
describe("forget_room", () => {
@ -769,6 +777,22 @@ describe("<MatrixChat />", () => {
),
).toBeInTheDocument();
});
it("should warn when user is the last admin", async () => {
jest.spyOn(room, "getJoinedMembers").mockReturnValue([
{ powerLevel: 100 } as unknown as MatrixJs.RoomMember,
{ powerLevel: 0 } as unknown as MatrixJs.RoomMember,
]);
jest.spyOn(room, "getMember").mockReturnValue({
powerLevel: 100,
} as unknown as MatrixJs.RoomMember);
dispatchAction();
await screen.findByRole("dialog");
expect(
screen.getByText(
"You're the only administrator in this room. If you leave, nobody will be able to change room settings or take other important actions.",
),
).toBeInTheDocument();
});
it("should do nothing on cancel", async () => {
dispatchAction();
const dialog = await screen.findByRole("dialog");

View File

@ -50,6 +50,7 @@ exports[`<RoomAvatarView /> should render a low priority room decoration 1`] = `
</span>
<svg
aria-label="This is a low priority room"
aria-labelledby="«r0»"
class="mx_RoomAvatarView_icon"
color="var(--cpd-color-icon-tertiary)"
fill="currentColor"
@ -92,6 +93,7 @@ exports[`<RoomAvatarView /> should render a public room decoration 1`] = `
</span>
<svg
aria-label="This room is public"
aria-labelledby="«rc»"
class="mx_RoomAvatarView_icon"
color="var(--cpd-color-icon-info-primary)"
fill="currentColor"
@ -134,6 +136,7 @@ exports[`<RoomAvatarView /> should render a video room decoration 1`] = `
</span>
<svg
aria-label="This room is a video room"
aria-labelledby="«r6»"
class="mx_RoomAvatarView_icon"
color="var(--cpd-color-icon-tertiary)"
fill="currentColor"
@ -176,6 +179,7 @@ exports[`<RoomAvatarView /> should render the AWAY presence 1`] = `
</span>
<svg
aria-label="Away"
aria-labelledby="«r14»"
class="mx_RoomAvatarView_PresenceDecoration"
color="var(--cpd-color-icon-quaternary)"
fill="currentColor"
@ -231,6 +235,7 @@ exports[`<RoomAvatarView /> should render the BUSY presence 1`] = `
</span>
<svg
aria-label="Busy"
aria-labelledby="«ru»"
class="mx_RoomAvatarView_PresenceDecoration"
color="var(--cpd-color-icon-tertiary)"
fill="currentColor"
@ -288,6 +293,7 @@ exports[`<RoomAvatarView /> should render the OFFLINE presence 1`] = `
</span>
<svg
aria-label="Offline"
aria-labelledby="«ro»"
class="mx_RoomAvatarView_PresenceDecoration"
color="var(--cpd-color-icon-tertiary)"
fill="currentColor"
@ -345,6 +351,7 @@ exports[`<RoomAvatarView /> should render the ONLINE presence 1`] = `
</span>
<svg
aria-label="Online"
aria-labelledby="«ri»"
class="mx_RoomAvatarView_PresenceDecoration"
color="var(--cpd-color-icon-accent-primary)"
fill="currentColor"

View File

@ -0,0 +1,50 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen } from "jest-matrix-react";
import { MatrixEvent, type RoomMember } from "matrix-js-sdk/src/matrix";
import LeaveSpaceDialog from "../../../../../src/components/views/dialogs/LeaveSpaceDialog";
import { createTestClient, mkStubRoom } from "../../../../test-utils";
describe("LeaveSpaceDialog", () => {
it("should warn about not being able to rejoin non-public space", () => {
const mockClient = createTestClient();
const mockSpace = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any;
jest.spyOn(mockSpace.currentState, "getStateEvents").mockReturnValue(
new MatrixEvent({
type: "m.room.join_rules",
content: {
join_rule: "invite",
},
}),
);
render(<LeaveSpaceDialog space={mockSpace} onFinished={jest.fn()} />);
expect(screen.getByText(/You won't be able to rejoin unless you are re-invited/)).toBeInTheDocument();
});
it("should warn if user is the only admin", () => {
const mockClient = createTestClient();
const mockSpace = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any;
jest.spyOn(mockSpace, "getJoinedMembers").mockReturnValue([
{ powerLevel: 100 } as unknown as RoomMember,
{ powerLevel: 0 } as unknown as RoomMember,
]);
jest.spyOn(mockSpace, "getMember").mockReturnValue({
powerLevel: 100,
} as unknown as RoomMember);
render(<LeaveSpaceDialog space={mockSpace} onFinished={jest.fn()} />);
expect(
screen.getByText(/You're the only admin of this space. Leaving it will mean no one has control over it./),
).toBeInTheDocument();
});
});

View File

@ -11,14 +11,39 @@ import { mocked } from "jest-mock";
import fetchMockJest from "fetch-mock-jest";
import { fireEvent, render, screen, waitFor } from "jest-matrix-react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import userEvent from "@testing-library/user-event";
import { stubClient } from "../../../../test-utils";
import { clearAllModals, stubClient } from "../../../../test-utils";
import DownloadActionButton from "../../../../../src/components/views/messages/DownloadActionButton";
import Modal from "../../../../../src/Modal";
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
import ErrorDialog from "../../../../../src/components/views/dialogs/ErrorDialog";
jest.mock("matrix-encrypt-attachment", () => ({
decryptAttachment: jest.fn().mockResolvedValue(new Blob(["TESTFILE"], { type: "application/octet-stream" })),
}));
describe("DownloadActionButton", () => {
const plainEvent = new MatrixEvent({
room_id: "!room:id",
sender: "@user:id",
type: "m.room.message",
content: {
body: "test",
msgtype: "m.image",
url: "mxc://matrix.org/1234",
},
});
beforeEach(() => {
jest.restoreAllMocks();
fetchMockJest.restore();
});
afterEach(() => {
clearAllModals();
});
it("should show error if media API returns one", async () => {
const cli = stubClient();
// eslint-disable-next-line no-restricted-properties
@ -26,24 +51,14 @@ describe("DownloadActionButton", () => {
(mxc) => `https://matrix.org/_matrix/media/r0/download/${mxc.slice(6)}`,
);
fetchMockJest.get("https://matrix.org/_matrix/media/r0/download/matrix.org/1234", {
fetchMockJest.getOnce("https://matrix.org/_matrix/media/r0/download/matrix.org/1234", {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "Not found" },
});
const event = new MatrixEvent({
room_id: "!room:id",
sender: "@user:id",
type: "m.room.message",
content: {
body: "test",
msgtype: "m.image",
url: "mxc://matrix.org/1234",
},
});
const mediaEventHelper = new MediaEventHelper(event);
const mediaEventHelper = new MediaEventHelper(plainEvent);
render(<DownloadActionButton mxEvent={event} mediaEventHelperGet={() => mediaEventHelper} />);
render(<DownloadActionButton mxEvent={plainEvent} mediaEventHelperGet={() => mediaEventHelper} />);
const spy = jest.spyOn(Modal, "createDialog");
@ -57,4 +72,85 @@ describe("DownloadActionButton", () => {
),
);
});
it("should show download tooltip on hover", async () => {
stubClient();
const user = userEvent.setup();
fetchMockJest.getOnce("https://matrix.org/_matrix/media/r0/download/matrix.org/1234", "TESTFILE");
const event = new MatrixEvent({
room_id: "!room:id",
sender: "@user:id",
type: "m.room.message",
content: {
body: "test",
msgtype: "m.image",
url: "mxc://matrix.org/1234",
},
});
render(<DownloadActionButton mxEvent={event} mediaEventHelperGet={() => undefined} />);
const button = screen.getByRole("button");
await user.hover(button);
await waitFor(() => {
expect(screen.getByRole("tooltip")).toHaveTextContent("Download");
});
});
it("should show downloading tooltip while unencrypted files are downloading", async () => {
const user = userEvent.setup();
stubClient();
fetchMockJest.getOnce("http://this.is.a.url/matrix.org/1234", "TESTFILE");
const mediaEventHelper = new MediaEventHelper(plainEvent);
render(<DownloadActionButton mxEvent={plainEvent} mediaEventHelperGet={() => mediaEventHelper} />);
const button = screen.getByRole("button");
await user.hover(button);
await user.click(button);
await waitFor(() => {
expect(screen.getByRole("tooltip")).toHaveTextContent("Downloading");
});
});
it("should show decrypting tooltip while encrypted files are downloading", async () => {
const user = userEvent.setup();
stubClient();
fetchMockJest.getOnce("http://this.is.a.url/matrix.org/1234", "UFTUGJMF");
const e2eEvent = new MatrixEvent({
room_id: "!room:id",
sender: "@user:id",
type: "m.room.message",
content: {
body: "test",
msgtype: "m.image",
file: { url: "mxc://matrix.org/1234" },
},
});
const mediaEventHelper = new MediaEventHelper(e2eEvent);
render(<DownloadActionButton mxEvent={e2eEvent} mediaEventHelperGet={() => mediaEventHelper} />);
const button = screen.getByRole("button");
await user.hover(button);
await user.click(button);
await waitFor(() => {
expect(screen.getByRole("tooltip")).toHaveTextContent("Decrypting");
});
});
});

View File

@ -35,7 +35,7 @@ describe("<EmptyRoomList />", () => {
expect(screen.getByText("No chats yet")).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
await user.click(screen.getByRole("button", { name: "New message" }));
await user.click(screen.getByRole("button", { name: "Start chat" }));
expect(vm.createChatRoom).toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "New room" }));

View File

@ -69,7 +69,7 @@ describe("<RoomListHeaderView />", () => {
expect(screen.queryByRole("button", { name: "Add" })).toBeNull();
expect(asFragment()).toMatchSnapshot();
await user.click(screen.getByRole("button", { name: "New message" }));
await user.click(screen.getByRole("button", { name: "Start chat" }));
expect(defaultValue.createChatRoom).toHaveBeenCalled();
});
@ -80,7 +80,7 @@ describe("<RoomListHeaderView />", () => {
const openMenu = screen.getByRole("button", { name: "Add" });
await user.click(openMenu);
await user.click(screen.getByRole("menuitem", { name: "New message" }));
await user.click(screen.getByRole("menuitem", { name: "Start chat" }));
expect(defaultValue.createChatRoom).toHaveBeenCalled();
await user.click(openMenu);

View File

@ -200,10 +200,10 @@ exports[`<EmptyRoomList /> should not render the new room button if the user doe
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 12q-1.65 0-2.825-1.175T6 8t1.175-2.825T10 4t2.825 1.175T14 8t-1.175 2.825T10 12m-8 6v-.8q0-.85.438-1.562.437-.713 1.162-1.088a14.8 14.8 0 0 1 3.15-1.163A13.8 13.8 0 0 1 10 13q1.65 0 3.25.387 1.6.388 3.15 1.163.724.375 1.163 1.087Q18 16.35 18 17.2v.8q0 .824-.587 1.413A1.93 1.93 0 0 1 16 20H4q-.824 0-1.412-.587A1.93 1.93 0 0 1 2 18m2 0h12v-.8a.97.97 0 0 0-.5-.85q-1.35-.675-2.725-1.012a11.6 11.6 0 0 0-5.55 0Q5.85 15.675 4.5 16.35a.97.97 0 0 0-.5.85zm6-8q.825 0 1.412-.588Q12 8.826 12 8q0-.824-.588-1.412A1.93 1.93 0 0 0 10 6q-.825 0-1.412.588A1.93 1.93 0 0 0 8 8q0 .825.588 1.412Q9.175 10 10 10m7 1h2v2q0 .424.288.713.287.287.712.287.424 0 .712-.287A.97.97 0 0 0 21 13v-2h2q.424 0 .712-.287A.97.97 0 0 0 24 10a.97.97 0 0 0-.288-.713A.97.97 0 0 0 23 9h-2V7a.97.97 0 0 0-.288-.713A.97.97 0 0 0 20 6a.97.97 0 0 0-.712.287A.97.97 0 0 0 19 7v2h-2a.97.97 0 0 0-.712.287A.97.97 0 0 0 16 10q0 .424.288.713.287.287.712.287"
d="m1.5 21.25 1.45-4.95a10.2 10.2 0 0 1-.712-2.1A10.2 10.2 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22q-1.125 0-2.2-.238a10.2 10.2 0 0 1-2.1-.712L2.75 22.5a.94.94 0 0 1-1-.25.94.94 0 0 1-.25-1m2.45-1.2 3.2-.95a1 1 0 0 1 .275-.062q.15-.013.275-.013.225 0 .438.038.212.036.412.137a7.4 7.4 0 0 0 1.675.6Q11.1 20 12 20q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12q0 .9.2 1.775t.6 1.675q.176.325.188.688t-.088.712z"
/>
</svg>
New message
Start chat
</button>
</div>
</div>
@ -247,10 +247,10 @@ exports[`<EmptyRoomList /> should render the default placeholder when there is n
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10 12q-1.65 0-2.825-1.175T6 8t1.175-2.825T10 4t2.825 1.175T14 8t-1.175 2.825T10 12m-8 6v-.8q0-.85.438-1.562.437-.713 1.162-1.088a14.8 14.8 0 0 1 3.15-1.163A13.8 13.8 0 0 1 10 13q1.65 0 3.25.387 1.6.388 3.15 1.163.724.375 1.163 1.087Q18 16.35 18 17.2v.8q0 .824-.587 1.413A1.93 1.93 0 0 1 16 20H4q-.824 0-1.412-.587A1.93 1.93 0 0 1 2 18m2 0h12v-.8a.97.97 0 0 0-.5-.85q-1.35-.675-2.725-1.012a11.6 11.6 0 0 0-5.55 0Q5.85 15.675 4.5 16.35a.97.97 0 0 0-.5.85zm6-8q.825 0 1.412-.588Q12 8.826 12 8q0-.824-.588-1.412A1.93 1.93 0 0 0 10 6q-.825 0-1.412.588A1.93 1.93 0 0 0 8 8q0 .825.588 1.412Q9.175 10 10 10m7 1h2v2q0 .424.288.713.287.287.712.287.424 0 .712-.287A.97.97 0 0 0 21 13v-2h2q.424 0 .712-.287A.97.97 0 0 0 24 10a.97.97 0 0 0-.288-.713A.97.97 0 0 0 23 9h-2V7a.97.97 0 0 0-.288-.713A.97.97 0 0 0 20 6a.97.97 0 0 0-.712.287A.97.97 0 0 0 19 7v2h-2a.97.97 0 0 0-.712.287A.97.97 0 0 0 16 10q0 .424.288.713.287.287.712.287"
d="m1.5 21.25 1.45-4.95a10.2 10.2 0 0 1-.712-2.1A10.2 10.2 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22q-1.125 0-2.2-.238a10.2 10.2 0 0 1-2.1-.712L2.75 22.5a.94.94 0 0 1-1-.25.94.94 0 0 1-.25-1m2.45-1.2 3.2-.95a1 1 0 0 1 .275-.062q.15-.013.275-.013.225 0 .438.038.212.036.412.137a7.4 7.4 0 0 0 1.675.6Q11.1 20 12 20q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12q0 .9.2 1.775t.6 1.675q.176.325.188.688t-.088.712z"
/>
</svg>
New message
Start chat
</button>
<button
class="_button_vczzf_8 _has-icon_vczzf_57"

View File

@ -217,7 +217,7 @@ exports[`<RoomListHeaderView /> compose menu should not display the compose menu
</div>
</button>
<button
aria-label="New message"
aria-label="Start chat"
class="_icon-button_1pz9o_8"
data-kind="primary"
role="button"

View File

@ -35,7 +35,9 @@ describe("MemberTileView", () => {
});
it("should not display an E2EIcon when the e2E status = normal", () => {
const { container } = render(<RoomMemberTileView member={member} index={0} memberCount={1} />);
const { container } = render(
<RoomMemberTileView member={member} index={0} memberCount={1} onFocus={jest.fn()} />,
);
const e2eIcon = container.querySelector(".mx_E2EIconView");
expect(e2eIcon).toBeNull();
expect(container).toMatchSnapshot();
@ -47,7 +49,9 @@ describe("MemberTileView", () => {
wasCrossSigningVerified: jest.fn().mockReturnValue(true),
} as unknown as UserVerificationStatus);
const { container } = render(<RoomMemberTileView member={member} index={0} memberCount={1} />);
const { container } = render(
<RoomMemberTileView member={member} index={0} memberCount={1} onFocus={jest.fn()} />,
);
await waitFor(async () => {
await userEvent.hover(container.querySelector(".mx_E2EIcon")!);
expect(screen.getByText("This user has not verified all of their sessions.")).toBeInTheDocument();
@ -68,7 +72,9 @@ describe("MemberTileView", () => {
crossSigningVerified: true,
} as DeviceVerificationStatus);
const { container } = render(<RoomMemberTileView member={member} index={0} memberCount={1} />);
const { container } = render(
<RoomMemberTileView member={member} index={0} memberCount={1} onFocus={jest.fn()} />,
);
await waitFor(async () => {
await userEvent.hover(container.querySelector(".mx_E2EIcon")!);
@ -81,15 +87,21 @@ describe("MemberTileView", () => {
it("renders user labels correctly", async () => {
member.powerLevel = 50;
const { container: container1 } = render(<RoomMemberTileView member={member} index={0} memberCount={1} />);
const { container: container1 } = render(
<RoomMemberTileView member={member} index={0} memberCount={1} onFocus={jest.fn()} />,
);
expect(container1).toHaveTextContent("Moderator");
member.powerLevel = 100;
const { container: container2 } = render(<RoomMemberTileView member={member} index={0} memberCount={1} />);
const { container: container2 } = render(
<RoomMemberTileView member={member} index={0} memberCount={1} onFocus={jest.fn()} />,
);
expect(container2).toHaveTextContent("Admin");
member.isInvite = true;
const { container: container3 } = render(<RoomMemberTileView member={member} index={0} memberCount={1} />);
const { container: container3 } = render(
<RoomMemberTileView member={member} index={0} memberCount={1} onFocus={jest.fn()} />,
);
expect(container3).toHaveTextContent("Invited");
});
});
@ -110,7 +122,12 @@ describe("MemberTileView", () => {
it("renders ThreePidInvite correctly", async () => {
const [{ threePidInvite }] = getPending3PidInvites(room);
const { container } = render(
<ThreePidInviteTileView threePidInvite={threePidInvite!} memberIndex={0} memberCount={1} />,
<ThreePidInviteTileView
threePidInvite={threePidInvite!}
memberIndex={0}
memberCount={1}
onFocus={jest.fn()}
/>,
);
expect(container).toMatchSnapshot();
});

View File

@ -305,6 +305,82 @@ describe("ListView", () => {
expect(container).toBeInTheDocument();
});
it("should not scroll to top when clicking an item after manual scroll", () => {
// Create a larger list to enable meaningful scrolling
const largerItems = Array.from({ length: 50 }, (_, i) => ({
id: `item-${i}`,
name: `Item ${i}`,
}));
const mockOnClick = jest.fn();
mockGetItemComponent.mockImplementation(
(index: number, item: TestItemWithSeparator, context: any, onFocus: (e: React.FocusEvent) => void) => {
const itemKey = typeof item === "string" ? item : item.id;
const isFocused = context.tabIndexKey === itemKey;
return (
<div
className="mx_item"
data-testid={`row-${index}`}
tabIndex={isFocused ? 0 : -1}
onClick={() => mockOnClick(item)}
onFocus={onFocus}
>
{item === SEPARATOR_ITEM ? "---" : (item as TestItem).name}
</div>
);
},
);
const { container } = renderListViewWithHeight({ items: largerItems });
const listContainer = screen.getByRole("grid");
// Step 1: Focus the list initially (this sets tabIndexKey to first item: "item-0")
fireEvent.focus(listContainer);
// Verify first item is focused initially and tabIndexKey is set to first item
let items = container.querySelectorAll(".mx_item");
expect(items[0]).toHaveAttribute("tabindex", "0");
expect(items[0]).toHaveAttribute("data-testid", "row-0");
// Step 2: Simulate manual scrolling (mouse wheel, scroll bar drag, etc.)
// This changes which items are visible but DOES NOT change tabIndexKey
// tabIndexKey should still point to "item-0" but "item-0" is no longer visible
fireEvent.scroll(listContainer, { target: { scrollTop: 300 } });
// Step 3: After scrolling, different items should now be visible
// but tabIndexKey should still point to "item-0" (which is no longer visible)
items = container.querySelectorAll(".mx_item");
// Verify that item-0 is no longer in the DOM (because it's scrolled out of view)
const item0 = container.querySelector("[data-testid='row-0']");
expect(item0).toBeNull();
// Find a visible item to click on (should be items from further down the list)
const visibleItems = container.querySelectorAll(".mx_item");
expect(visibleItems.length).toBeGreaterThan(0);
const clickTargetItem = visibleItems[0]; // Click on the first visible item
// Click on the visible item
fireEvent.click(clickTargetItem);
// The click should trigger the onFocus callback, which updates the tabIndexKey
// This simulates the real user interaction where clicking an item focuses it
fireEvent.focus(clickTargetItem);
// Verify the click was handled
expect(mockOnClick).toHaveBeenCalled();
// With the fix applied: the clicked item should become focused (tabindex="0")
// This validates that the fix prevents unwanted scrolling back to the top
expect(clickTargetItem).toHaveAttribute("tabindex", "0");
// The key validation: ensure we haven't scrolled back to the top
// item-0 should still not be visible (if the fix is working)
const item0AfterClick = container.querySelector("[data-testid='row-0']");
expect(item0AfterClick).toBeNull();
});
});
describe("Accessibility", () => {

View File

@ -440,6 +440,17 @@ describe("RoomViewStore", function () {
});
expect(mocked(dis.dispatch).mock.calls[2][0]).toEqual({ action: "prompt_ask_to_join" });
});
it("sets 'acceptSharedHistory' if that option is enabled", async () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName, roomId, value) => {
return settingName === "feature_share_history_on_invite"; // this is enabled, everything else is disabled.
});
dis.dispatch({ action: Action.ViewRoom, room_id: roomId });
dis.dispatch({ action: Action.JoinRoom });
await untilDispatch(Action.JoinRoomReady, dis);
expect(mockClient.joinRoom).toHaveBeenCalledWith(roomId, { acceptSharedHistory: true, viaServers: [] });
});
});
describe("Action.JoinRoomError", () => {

View File

@ -96,9 +96,9 @@ describe("MultiInviter", () => {
const result = await inviter.invite([MXID1, MXID2, MXID3]);
expect(client.invite).toHaveBeenCalledTimes(3);
expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, undefined);
expect(client.invite).toHaveBeenNthCalledWith(2, ROOMID, MXID2, undefined);
expect(client.invite).toHaveBeenNthCalledWith(3, ROOMID, MXID3, undefined);
expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, {});
expect(client.invite).toHaveBeenNthCalledWith(2, ROOMID, MXID2, {});
expect(client.invite).toHaveBeenNthCalledWith(3, ROOMID, MXID3, {});
expectAllInvitedResult(result);
});
@ -114,9 +114,9 @@ describe("MultiInviter", () => {
const result = await inviter.invite([MXID1, MXID2, MXID3]);
expect(client.invite).toHaveBeenCalledTimes(3);
expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, undefined);
expect(client.invite).toHaveBeenNthCalledWith(2, ROOMID, MXID2, undefined);
expect(client.invite).toHaveBeenNthCalledWith(3, ROOMID, MXID3, undefined);
expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, {});
expect(client.invite).toHaveBeenNthCalledWith(2, ROOMID, MXID2, {});
expect(client.invite).toHaveBeenNthCalledWith(3, ROOMID, MXID3, {});
expectAllInvitedResult(result);
});
@ -129,7 +129,7 @@ describe("MultiInviter", () => {
const result = await inviter.invite([MXID1, MXID2, MXID3]);
expect(client.invite).toHaveBeenCalledTimes(1);
expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, undefined);
expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, {});
// The resolved state is 'invited' for all users.
// With the above client expectations, the test ensures that only the first user is invited.
@ -231,5 +231,15 @@ describe("MultiInviter", () => {
`"This space is unfederated. You cannot invite people from external servers."`,
);
});
it("should set shareEncryptedHistory if that setting is enabled", async () => {
mocked(SettingsStore.getValue).mockImplementation((settingName, roomId, value) => {
return settingName === "feature_share_history_on_invite"; // this is enabled, everything else is disabled.
});
await inviter.invite([MXID1]);
expect(client.invite).toHaveBeenCalledTimes(1);
expect(client.invite).toHaveBeenNthCalledWith(1, ROOMID, MXID1, { shareEncryptedHistory: true });
});
});
});

View File

@ -1687,10 +1687,10 @@
resolved "https://registry.yarnpkg.com/@element-hq/element-web-module-api/-/element-web-module-api-1.3.0.tgz#6067fa654174d1dd0953447bb036e38f9dfa51a5"
integrity sha512-rEV0xnT/tNYPIdqHWWiz2KZo96UeZR0YChfoVLiPT46ZlEYyxqkjxT5bOm1eL2/CiYRe8t1yka3UDkIjq481/g==
"@element-hq/element-web-playwright-common@^1.4.3":
version "1.4.3"
resolved "https://registry.yarnpkg.com/@element-hq/element-web-playwright-common/-/element-web-playwright-common-1.4.3.tgz#c33217032e805a0668fbf3fa09929aac9acedb09"
integrity sha512-WrvScEsXTBreYmOMK2AiAA/ifAbgOrctolex2LRO0Z0TUkDF5Bh2sg6MBTK8i11EO+ifsy2eCLJtAQ//Yzj1GA==
"@element-hq/element-web-playwright-common@^1.4.4":
version "1.4.4"
resolved "https://registry.yarnpkg.com/@element-hq/element-web-playwright-common/-/element-web-playwright-common-1.4.4.tgz#d58dba7b5b4198f2fc137e1bdd1ad82c2cee46fb"
integrity sha512-QnWz8dlRuQHZYZT9ewrcN++l7gQ0Kf+oZwMCi0k1TBf8Za40r5ibNrgZqZYyCoItBc8LGTVL3yOrUfzN4Dm2Qw==
dependencies:
"@axe-core/playwright" "^4.10.1"
"@testcontainers/postgresql" "^11.0.0"
@ -4552,7 +4552,7 @@
resolved "https://registry.yarnpkg.com/@vector-im/matrix-wysiwyg/-/matrix-wysiwyg-2.39.0.tgz#a6238e517f23a2f3025d9c65445914771c63b163"
integrity sha512-OROXnzPcQWrCMoUpIrCKEC4FYU+9SsRomUgu+VbJwWtBDkCbfvLD4z6w/mgiADw3iTUpBPgmcWJoGxesFuB20Q==
dependencies:
"@vector-im/matrix-wysiwyg-wasm" "link:../../../.cache/yarn/v6/npm-@vector-im-matrix-wysiwyg-2.39.0-a6238e517f23a2f3025d9c65445914771c63b163-integrity/node_modules/bindings/wysiwyg-wasm"
"@vector-im/matrix-wysiwyg-wasm" "link:../../Library/Caches/Yarn/v6/npm-@vector-im-matrix-wysiwyg-2.39.0-a6238e517f23a2f3025d9c65445914771c63b163-integrity/node_modules/bindings/wysiwyg-wasm"
"@vitest/expect@3.2.4":
version "3.2.4"
@ -10510,20 +10510,20 @@ linkify-it@^4.0.1:
dependencies:
uc.micro "^1.0.1"
linkify-react@4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/linkify-react/-/linkify-react-4.3.1.tgz#0655632d654a881e54d955ec12b1ab817d879f50"
integrity sha512-w8ahBdCwF9C/doS4V3nE93QF1oyORmosvi8UEUbpHYws077eGzhkxUzJQcE2/SU5Q2K7SD80M4ybwwZGHErx5Q==
linkify-react@4.3.2:
version "4.3.2"
resolved "https://registry.yarnpkg.com/linkify-react/-/linkify-react-4.3.2.tgz#8d47fb0ad96ab5b38c07bfbebdcbc57794430693"
integrity sha512-mi744h1hf+WDsr+paJgSBBgYNLMWNSHyM9V9LVUo03RidNGdw1VpI7Twnt+K3pEh3nIzB4xiiAgZxpd61ItKpQ==
linkify-string@4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/linkify-string/-/linkify-string-4.3.1.tgz#d6f8b7166d588a64943e3bb23302ce44047f61a2"
integrity sha512-1AnH52wZwuJi+skG/9dUphhQEUblVGSf0ntkM8z21RS9bF7xR0qPpqnNTyCo2Obqs5MR5wi8y5wOLPoBbzxm2w==
linkify-string@4.3.2:
version "4.3.2"
resolved "https://registry.yarnpkg.com/linkify-string/-/linkify-string-4.3.2.tgz#535f7a3c25a8c83b862aa3263d6cb09fd4e4b3f4"
integrity sha512-JqBuQpSa+CSj2tskIII70SKOjPfjXwDFyjRRNFTrlg76gp2nap36xeRj/cWaXxukqBNrxM+L07XyKRsUtH/DpQ==
linkifyjs@4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.3.1.tgz#1f246ebf4be040002accd1f4535b6af7c7e37898"
integrity sha512-DRSlB9DKVW04c4SUdGvKK5FR6be45lTU9M76JnngqPeeGDqPwYc0zdUErtsNVMtxPXgUWV4HbXbnC4sNyBxkYg==
linkifyjs@4.3.2:
version "4.3.2"
resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.3.2.tgz#d97eb45419aabf97ceb4b05a7adeb7b8c8ade2b1"
integrity sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==
lint-staged@^16.0.0:
version "16.1.2"