Merge branch 'develop' into t3chguy/wat/230.1

This commit is contained in:
Michael Telatynski 2024-09-18 11:21:55 +01:00 committed by GitHub
commit 7feb5a0b49
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 523 additions and 155 deletions

View file

@ -6,9 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import { Page } from "@playwright/test";
import { expect, test } from "../../element-web-test"; import { expect, test } from "../../element-web-test";
import { autoJoin, createSharedRoomWithUser, enableKeyBackup, logIntoElement, logOutOfElement, verify } from "./utils"; import { autoJoin, createSharedRoomWithUser, enableKeyBackup, logIntoElement, logOutOfElement, verify } from "./utils";
import { Bot } from "../../pages/bot"; import { Bot } from "../../pages/bot";
import { HomeserverInstance } from "../../plugins/homeserver";
test.describe("Cryptography", function () { test.describe("Cryptography", function () {
test.use({ test.use({
@ -41,16 +44,14 @@ test.describe("Cryptography", function () {
}); });
}); });
test("should show the correct shield on e2e events", async ({ page, app, bot: bob, homeserver }) => { test("should show the correct shield on e2e events", async ({
page,
app,
bot: bob,
homeserver,
}, workerInfo) => {
// Bob has a second, not cross-signed, device // Bob has a second, not cross-signed, device
const bobSecondDevice = new Bot(page, homeserver, { const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob);
bootstrapSecretStorage: false,
bootstrapCrossSigning: false,
});
bobSecondDevice.setCredentials(
await homeserver.loginUser(bob.credentials.userId, bob.credentials.password),
);
await bobSecondDevice.prepareClient();
await bob.sendEvent(testRoomId, null, "m.room.encrypted", { await bob.sendEvent(testRoomId, null, "m.room.encrypted", {
algorithm: "m.megolm.v1.aes-sha2", algorithm: "m.megolm.v1.aes-sha2",
@ -117,7 +118,10 @@ test.describe("Cryptography", function () {
await lastTileE2eIcon.focus(); await lastTileE2eIcon.focus();
await expect(page.getByRole("tooltip")).toContainText("Encrypted by a device not verified by its owner."); await expect(page.getByRole("tooltip")).toContainText("Encrypted by a device not verified by its owner.");
/* Should show a grey padlock for a message from an unknown device */ /* In legacy crypto: should show a grey padlock for a message from a deleted device.
* In rust crypto: should show a red padlock for a message from an unverified device.
* Rust crypto remembers the verification state of the sending device, so it will know that the device was
* unverified, even if it gets deleted. */
// bob deletes his second device // bob deletes his second device
await bobSecondDevice.evaluate((cli) => cli.logout(true)); await bobSecondDevice.evaluate((cli) => cli.logout(true));
@ -148,7 +152,11 @@ test.describe("Cryptography", function () {
await expect(last).toContainText("test encrypted from unverified"); await expect(last).toContainText("test encrypted from unverified");
await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/);
await lastE2eIcon.focus(); await lastE2eIcon.focus();
await expect(page.getByRole("tooltip")).toContainText("Encrypted by an unknown or deleted device."); await expect(page.getByRole("tooltip")).toContainText(
workerInfo.project.name === "Legacy Crypto"
? "Encrypted by an unknown or deleted device."
: "Encrypted by a device not verified by its owner.",
);
}); });
test("Should show a grey padlock for a key restored from backup", async ({ test("Should show a grey padlock for a key restored from backup", async ({
@ -204,14 +212,7 @@ test.describe("Cryptography", function () {
test("should show the correct shield on edited e2e events", async ({ page, app, bot: bob, homeserver }) => { test("should show the correct shield on edited e2e events", async ({ page, app, bot: bob, homeserver }) => {
// bob has a second, not cross-signed, device // bob has a second, not cross-signed, device
const bobSecondDevice = new Bot(page, homeserver, { const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob);
bootstrapSecretStorage: false,
bootstrapCrossSigning: false,
});
bobSecondDevice.setCredentials(
await homeserver.loginUser(bob.credentials.userId, bob.credentials.password),
);
await bobSecondDevice.prepareClient();
// verify Bob // verify Bob
await verify(app, bob); await verify(app, bob);
@ -257,5 +258,51 @@ test.describe("Cryptography", function () {
page.locator(".mx_EventTile", { hasText: "Hee!" }).locator(".mx_EventTile_e2eIcon_warning"), page.locator(".mx_EventTile", { hasText: "Hee!" }).locator(".mx_EventTile_e2eIcon_warning"),
).not.toBeVisible(); ).not.toBeVisible();
}); });
test("should show correct shields on events sent by devices which have since been deleted", async ({
page,
app,
bot: bob,
homeserver,
}) => {
// Our app is blocked from syncing while Bob sends his messages.
await app.client.network.goOffline();
// Bob sends a message from his verified device
await bob.sendMessage(testRoomId, "test encrypted from verified");
// And one from a second, not cross-signed, device
const bobSecondDevice = await createSecondBotDevice(page, homeserver, bob);
await bobSecondDevice.waitForNextSync(); // make sure the client knows the room is encrypted
await bobSecondDevice.sendMessage(testRoomId, "test encrypted from unverified");
// ... and then logs out both devices.
await bob.evaluate((cli) => cli.logout(true));
await bobSecondDevice.evaluate((cli) => cli.logout(true));
// Let our app start syncing again
await app.client.network.goOnline();
// Wait for the messages to arrive
const last = page.locator(".mx_EventTile_last");
await expect(last).toContainText("test encrypted from unverified");
const lastE2eIcon = last.locator(".mx_EventTile_e2eIcon");
await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/);
await lastE2eIcon.focus();
await expect(page.getByRole("tooltip")).toContainText("Encrypted by a device not verified by its owner.");
const penultimate = page.locator(".mx_EventTile").filter({ hasText: "test encrypted from verified" });
await expect(penultimate.locator(".mx_EventTile_e2eIcon")).not.toBeVisible();
});
}); });
}); });
async function createSecondBotDevice(page: Page, homeserver: HomeserverInstance, bob: Bot) {
const bobSecondDevice = new Bot(page, homeserver, {
bootstrapSecretStorage: false,
bootstrapCrossSigning: false,
});
bobSecondDevice.setCredentials(await homeserver.loginUser(bob.credentials.userId, bob.credentials.password));
await bobSecondDevice.prepareClient();
return bobSecondDevice;
}

View file

@ -20,7 +20,7 @@ import { randB64Bytes } from "../../utils/rand";
// Docker tag to use for synapse docker image. // Docker tag to use for synapse docker image.
// We target a specific digest as every now and then a Synapse update will break our CI. // We target a specific digest as every now and then a Synapse update will break our CI.
// This digest is updated by the playwright-image-updates.yaml workflow periodically. // This digest is updated by the playwright-image-updates.yaml workflow periodically.
const DOCKER_TAG = "develop@sha256:e69f01d085a69269c892dfa899cb274a593f0fbb4c518eac2b530319fa43c7cb"; const DOCKER_TAG = "develop@sha256:117a94ee66e4049eb6f40d04cc70d4fc83f7022dacc9871448c141e7756492f9";
async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise<Omit<HomeserverConfig, "dockerUrl">> { async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise<Omit<HomeserverConfig, "dockerUrl">> {
const templateDir = path.join(__dirname, "templates", opts.template); const templateDir = path.join(__dirname, "templates", opts.template);

View file

@ -94,6 +94,7 @@
@import "./structures/auth/_ConfirmSessionLockTheftView.pcss"; @import "./structures/auth/_ConfirmSessionLockTheftView.pcss";
@import "./structures/auth/_Login.pcss"; @import "./structures/auth/_Login.pcss";
@import "./structures/auth/_LoginSplashView.pcss"; @import "./structures/auth/_LoginSplashView.pcss";
@import "./structures/auth/_MobileRegistration.pcss";
@import "./structures/auth/_Registration.pcss"; @import "./structures/auth/_Registration.pcss";
@import "./structures/auth/_SessionLockStolenView.pcss"; @import "./structures/auth/_SessionLockStolenView.pcss";
@import "./structures/auth/_SetupEncryptionBody.pcss"; @import "./structures/auth/_SetupEncryptionBody.pcss";

View file

@ -0,0 +1,10 @@
/*
Copyright 2024 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
.mx_MobileRegister_body {
padding: 32px;
}

View file

@ -561,7 +561,6 @@ const onPinnedMessagesClick = (): void => {
}; };
function textForPinnedEvent(event: MatrixEvent, client: MatrixClient, allowJSX: boolean): (() => Renderable) | null { function textForPinnedEvent(event: MatrixEvent, client: MatrixClient, allowJSX: boolean): (() => Renderable) | null {
if (!SettingsStore.getValue("feature_pinning")) return null;
const senderName = getSenderName(event); const senderName = getSenderName(event);
const roomId = event.getRoomId()!; const roomId = event.getRoomId()!;

View file

@ -20,6 +20,7 @@ import { logger } from "matrix-js-sdk/src/logger";
import getEntryComponentForLoginType, { import getEntryComponentForLoginType, {
ContinueKind, ContinueKind,
CustomAuthType,
IStageComponent, IStageComponent,
} from "../views/auth/InteractiveAuthEntryComponents"; } from "../views/auth/InteractiveAuthEntryComponents";
import Spinner from "../views/elements/Spinner"; import Spinner from "../views/elements/Spinner";
@ -75,11 +76,11 @@ export interface InteractiveAuthProps<T> {
// Called when the stage changes, or the stage's phase changes. First // Called when the stage changes, or the stage's phase changes. First
// argument is the stage, second is the phase. Some stages do not have // argument is the stage, second is the phase. Some stages do not have
// phases and will be counted as 0 (numeric). // phases and will be counted as 0 (numeric).
onStagePhaseChange?(stage: AuthType | null, phase: number): void; onStagePhaseChange?(stage: AuthType | CustomAuthType | null, phase: number): void;
} }
interface IState { interface IState {
authStage?: AuthType; authStage?: CustomAuthType | AuthType;
stageState?: IStageStatus; stageState?: IStageStatus;
busy: boolean; busy: boolean;
errorText?: string; errorText?: string;

View file

@ -140,7 +140,7 @@ import { cleanUpDraftsIfRequired } from "../../DraftCleaner";
// legacy export // legacy export
export { default as Views } from "../../Views"; export { default as Views } from "../../Views";
const AUTH_SCREENS = ["register", "login", "forgot_password", "start_sso", "start_cas", "welcome"]; const AUTH_SCREENS = ["register", "mobile_register", "login", "forgot_password", "start_sso", "start_cas", "welcome"];
// Actions that are redirected through the onboarding process prior to being // Actions that are redirected through the onboarding process prior to being
// re-dispatched. NOTE: some actions are non-trivial and would require // re-dispatched. NOTE: some actions are non-trivial and would require
@ -189,6 +189,7 @@ interface IState {
register_session_id?: string; register_session_id?: string;
// eslint-disable-next-line camelcase // eslint-disable-next-line camelcase
register_id_sid?: string; register_id_sid?: string;
isMobileRegistration?: boolean;
// When showing Modal dialogs we need to set aria-hidden on the root app element // When showing Modal dialogs we need to set aria-hidden on the root app element
// and disable it when there are no dialogs // and disable it when there are no dialogs
hideToSRUsers: boolean; hideToSRUsers: boolean;
@ -243,6 +244,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
currentUserId: null, currentUserId: null,
hideToSRUsers: false, hideToSRUsers: false,
isMobileRegistration: false,
syncError: null, // If the current syncing status is ERROR, the error object, otherwise null. syncError: null, // If the current syncing status is ERROR, the error object, otherwise null.
resizeNotifier: new ResizeNotifier(), resizeNotifier: new ResizeNotifier(),
@ -650,6 +652,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
case "require_registration": case "require_registration":
startAnyRegistrationFlow(payload as any); startAnyRegistrationFlow(payload as any);
break; break;
case "start_mobile_registration":
this.startRegistration(payload.params || {}, true);
break;
case "start_registration": case "start_registration":
if (Lifecycle.isSoftLogout()) { if (Lifecycle.isSoftLogout()) {
this.onSoftLogout(); this.onSoftLogout();
@ -946,19 +951,28 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}); });
} }
private async startRegistration(params: { [key: string]: string }): Promise<void> { private async startRegistration(params: { [key: string]: string }, isMobileRegistration?: boolean): Promise<void> {
if (!SettingsStore.getValue(UIFeature.Registration)) { if (!SettingsStore.getValue(UIFeature.Registration)) {
this.showScreen("welcome"); this.showScreen("welcome");
return; return;
} }
const isMobileRegistrationAllowed =
isMobileRegistration && SettingsStore.getValue("Registration.mobileRegistrationHelper");
const newState: Partial<IState> = { const newState: Partial<IState> = {
view: Views.REGISTER, view: Views.REGISTER,
}; };
// Only honour params if they are all present, otherwise we reset if (isMobileRegistrationAllowed && params.hs_url) {
// HS and IS URLs when switching to registration. try {
if (params.client_secret && params.session_id && params.hs_url && params.is_url && params.sid) { const config = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(params.hs_url);
newState.serverConfig = config;
} catch (err) {
logger.warn("Failed to load hs_url param:", params.hs_url);
}
} else if (params.client_secret && params.session_id && params.hs_url && params.is_url && params.sid) {
// Only honour params if they are all present, otherwise we reset
// HS and IS URLs when switching to registration.
newState.serverConfig = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls( newState.serverConfig = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
params.hs_url, params.hs_url,
params.is_url, params.is_url,
@ -978,10 +992,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
newState.register_id_sid = params.sid; newState.register_id_sid = params.sid;
} }
newState.isMobileRegistration = isMobileRegistrationAllowed;
this.setStateForNewView(newState); this.setStateForNewView(newState);
ThemeController.isLogin = true; ThemeController.isLogin = true;
this.themeWatcher.recheck(); this.themeWatcher.recheck();
this.notifyNewScreen("register"); this.notifyNewScreen(isMobileRegistrationAllowed ? "mobile_register" : "register");
} }
// switch view to the given room // switch view to the given room
@ -1721,6 +1737,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
params: params, params: params,
}); });
PerformanceMonitor.instance.start(PerformanceEntryNames.REGISTER); PerformanceMonitor.instance.start(PerformanceEntryNames.REGISTER);
} else if (screen === "mobile_register") {
dis.dispatch({
action: "start_mobile_registration",
params: params,
});
} else if (screen === "login") { } else if (screen === "login") {
dis.dispatch({ dis.dispatch({
action: "start_login", action: "start_login",
@ -2080,6 +2101,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
onServerConfigChange={this.onServerConfigChange} onServerConfigChange={this.onServerConfigChange}
defaultDeviceDisplayName={this.props.defaultDeviceDisplayName} defaultDeviceDisplayName={this.props.defaultDeviceDisplayName}
fragmentAfterLogin={fragmentAfterLogin} fragmentAfterLogin={fragmentAfterLogin}
mobileRegister={this.state.isMobileRegistration}
{...this.getServerProperties()} {...this.getServerProperties()}
/> />
); );

View file

@ -17,7 +17,6 @@ import RightPanelStore from "../../stores/right-panel/RightPanelStore";
import MatrixClientContext from "../../contexts/MatrixClientContext"; import MatrixClientContext from "../../contexts/MatrixClientContext";
import RoomSummaryCard from "../views/right_panel/RoomSummaryCard"; import RoomSummaryCard from "../views/right_panel/RoomSummaryCard";
import WidgetCard from "../views/right_panel/WidgetCard"; import WidgetCard from "../views/right_panel/WidgetCard";
import SettingsStore from "../../settings/SettingsStore";
import MemberList from "../views/rooms/MemberList"; import MemberList from "../views/rooms/MemberList";
import UserInfo from "../views/right_panel/UserInfo"; import UserInfo from "../views/right_panel/UserInfo";
import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo"; import ThirdPartyMemberInfo from "../views/rooms/ThirdPartyMemberInfo";
@ -220,7 +219,7 @@ export default class RightPanel extends React.Component<Props, IState> {
break; break;
case RightPanelPhases.PinnedMessages: case RightPanelPhases.PinnedMessages:
if (!!this.props.room && SettingsStore.getValue("feature_pinning")) { if (!!this.props.room) {
card = ( card = (
<PinnedMessagesCard <PinnedMessagesCard
room={this.props.room} room={this.props.room}

View file

@ -2408,13 +2408,9 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
</AuxPanel> </AuxPanel>
); );
const isPinningEnabled = SettingsStore.getValue<boolean>("feature_pinning"); const pinnedMessageBanner = (
let pinnedMessageBanner; <PinnedMessageBanner room={this.state.room} permalinkCreator={this.permalinkCreator} />
if (isPinningEnabled) { );
pinnedMessageBanner = (
<PinnedMessageBanner room={this.state.room} permalinkCreator={this.permalinkCreator} />
);
}
let messageComposer; let messageComposer;
const showComposer = const showComposer =

View file

@ -53,6 +53,13 @@ const debuglog = (...args: any[]): void => {
} }
}; };
export interface MobileRegistrationResponse {
user_id: string;
home_server: string;
access_token: string;
device_id: string;
}
interface IProps { interface IProps {
serverConfig: ValidatedServerConfig; serverConfig: ValidatedServerConfig;
defaultDeviceDisplayName?: string; defaultDeviceDisplayName?: string;
@ -62,7 +69,7 @@ interface IProps {
sessionId?: string; sessionId?: string;
idSid?: string; idSid?: string;
fragmentAfterLogin?: string; fragmentAfterLogin?: string;
mobileRegister?: boolean;
// Called when the user has logged in. Params: // Called when the user has logged in. Params:
// - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken // - object with userId, deviceId, homeserverUrl, identityServerUrl, accessToken
// - The user's password, if available and applicable (may be cached in memory // - The user's password, if available and applicable (may be cached in memory
@ -410,18 +417,33 @@ export default class Registration extends React.Component<IProps, IState> {
debuglog("Registration: ui auth finished:", { hasEmail, hasAccessToken }); debuglog("Registration: ui auth finished:", { hasEmail, hasAccessToken });
// dont log in if we found a session for a different user // dont log in if we found a session for a different user
if (hasAccessToken && !newState.differentLoggedInUserId) { if (hasAccessToken && !newState.differentLoggedInUserId) {
await this.props.onLoggedIn( if (this.props.mobileRegister) {
{ const mobileResponse: MobileRegistrationResponse = {
userId, user_id: userId,
deviceId: (response as RegisterResponse).device_id!, home_server: this.state.matrixClient.getHomeserverUrl(),
homeserverUrl: this.state.matrixClient.getHomeserverUrl(), access_token: accessToken,
identityServerUrl: this.state.matrixClient.getIdentityServerUrl(), device_id: (response as RegisterResponse).device_id!,
accessToken, };
}, const event = new CustomEvent<MobileRegistrationResponse>("mobileregistrationresponse", {
this.state.formVals.password!, detail: mobileResponse,
); });
window.dispatchEvent(event);
newState.busy = false;
newState.completedNoSignin = true;
} else {
await this.props.onLoggedIn(
{
userId,
deviceId: (response as RegisterResponse).device_id!,
homeserverUrl: this.state.matrixClient.getHomeserverUrl(),
identityServerUrl: this.state.matrixClient.getIdentityServerUrl(),
accessToken,
},
this.state.formVals.password!,
);
this.setupPushers(); this.setupPushers();
}
} else { } else {
newState.busy = false; newState.busy = false;
newState.completedNoSignin = true; newState.completedNoSignin = true;
@ -558,7 +580,7 @@ export default class Registration extends React.Component<IProps, IState> {
); );
} else if (this.state.matrixClient && this.state.flows.length) { } else if (this.state.matrixClient && this.state.flows.length) {
let ssoSection: JSX.Element | undefined; let ssoSection: JSX.Element | undefined;
if (this.state.ssoFlow) { if (!this.props.mobileRegister && this.state.ssoFlow) {
let continueWithSection; let continueWithSection;
const providers = this.state.ssoFlow.identity_providers || []; const providers = this.state.ssoFlow.identity_providers || [];
// when there is only a single (or 0) providers we show a wide button with `Continue with X` text // when there is only a single (or 0) providers we show a wide button with `Continue with X` text
@ -591,7 +613,6 @@ export default class Registration extends React.Component<IProps, IState> {
</React.Fragment> </React.Fragment>
); );
} }
return ( return (
<React.Fragment> <React.Fragment>
{ssoSection} {ssoSection}
@ -660,7 +681,9 @@ export default class Registration extends React.Component<IProps, IState> {
let body; let body;
if (this.state.completedNoSignin) { if (this.state.completedNoSignin) {
let regDoneText; let regDoneText;
if (this.state.differentLoggedInUserId) { if (this.props.mobileRegister) {
regDoneText = undefined;
} else if (this.state.differentLoggedInUserId) {
regDoneText = ( regDoneText = (
<div> <div>
<p> <p>
@ -717,6 +740,15 @@ export default class Registration extends React.Component<IProps, IState> {
{regDoneText} {regDoneText}
</div> </div>
); );
} else if (this.props.mobileRegister) {
body = (
<Fragment>
<h1>{_t("auth|mobile_create_account_title", { hsName: this.props.serverConfig.hsName })}</h1>
{errorText}
{serverDeadSection}
{this.renderRegisterComponent()}
</Fragment>
);
} else { } else {
body = ( body = (
<Fragment> <Fragment>
@ -746,7 +778,9 @@ export default class Registration extends React.Component<IProps, IState> {
</Fragment> </Fragment>
); );
} }
if (this.props.mobileRegister) {
return <div className="mx_MobileRegister_body">{body}</div>;
}
return ( return (
<AuthPage> <AuthPage>
<AuthHeader /> <AuthHeader />

View file

@ -18,7 +18,6 @@ import DateSeparator from "../../views/messages/DateSeparator";
import HistoryTile from "../../views/rooms/HistoryTile"; import HistoryTile from "../../views/rooms/HistoryTile";
import EventListSummary from "../../views/elements/EventListSummary"; import EventListSummary from "../../views/elements/EventListSummary";
import { SeparatorKind } from "../../views/messages/TimelineSeparator"; import { SeparatorKind } from "../../views/messages/TimelineSeparator";
import SettingsStore from "../../../settings/SettingsStore";
const groupedStateEvents = [ const groupedStateEvents = [
EventType.RoomMember, EventType.RoomMember,
@ -91,7 +90,7 @@ export class MainGrouper extends BaseGrouper {
return; return;
} }
if (ev.getType() === EventType.RoomPinnedEvents && !SettingsStore.getValue("feature_pinning")) { if (ev.getType() === EventType.RoomPinnedEvents) {
// If pinned messages are disabled, don't show the summary // If pinned messages are disabled, don't show the summary
return; return;
} }

View file

@ -11,6 +11,8 @@ import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { AuthType, AuthDict, IInputs, IStageStatus } from "matrix-js-sdk/src/interactive-auth"; import { AuthType, AuthDict, IInputs, IStageStatus } from "matrix-js-sdk/src/interactive-auth";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import React, { ChangeEvent, createRef, FormEvent, Fragment } from "react"; import React, { ChangeEvent, createRef, FormEvent, Fragment } from "react";
import { Button, Text } from "@vector-im/compound-web";
import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-out";
import EmailPromptIcon from "../../../../res/img/element-icons/email-prompt.svg"; import EmailPromptIcon from "../../../../res/img/element-icons/email-prompt.svg";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
@ -21,6 +23,7 @@ import AccessibleButton, { AccessibleButtonKind, ButtonEvent } from "../elements
import Field from "../elements/Field"; import Field from "../elements/Field";
import Spinner from "../elements/Spinner"; import Spinner from "../elements/Spinner";
import CaptchaForm from "./CaptchaForm"; import CaptchaForm from "./CaptchaForm";
import { Flex } from "../../utils/Flex";
/* This file contains a collection of components which are used by the /* This file contains a collection of components which are used by the
* InteractiveAuth to prompt the user to enter the information needed * InteractiveAuth to prompt the user to enter the information needed
@ -905,11 +908,11 @@ export class SSOAuthEntry extends React.Component<ISSOAuthEntryProps, ISSOAuthEn
} }
} }
export class FallbackAuthEntry extends React.Component<IAuthEntryProps> { export class FallbackAuthEntry<T = {}> extends React.Component<IAuthEntryProps & T> {
private popupWindow: Window | null; protected popupWindow: Window | null;
private fallbackButton = createRef<HTMLButtonElement>(); protected fallbackButton = createRef<HTMLButtonElement>();
public constructor(props: IAuthEntryProps) { public constructor(props: IAuthEntryProps & T) {
super(props); super(props);
// we have to make the user click a button, as browsers will block // we have to make the user click a button, as browsers will block
@ -967,6 +970,50 @@ export class FallbackAuthEntry extends React.Component<IAuthEntryProps> {
} }
} }
export enum CustomAuthType {
// Workaround for MAS requiring non-UIA authentication for resetting cross-signing.
MasCrossSigningReset = "org.matrix.cross_signing_reset",
}
export class MasUnlockCrossSigningAuthEntry extends FallbackAuthEntry<{
stageParams?: {
url?: string;
};
}> {
public static LOGIN_TYPE = CustomAuthType.MasCrossSigningReset;
private onGoToAccountClick = (): void => {
if (!this.props.stageParams?.url) return;
this.popupWindow = window.open(this.props.stageParams.url, "_blank");
};
private onRetryClick = (): void => {
this.props.submitAuthDict({});
};
public render(): React.ReactNode {
return (
<div>
<Text>{_t("auth|uia|mas_cross_signing_reset_description")}</Text>
<Flex gap="var(--cpd-space-4x)">
<Button
Icon={PopOutIcon}
onClick={this.onGoToAccountClick}
autoFocus
kind="primary"
className="mx_Dialog_nonDialogButton"
>
{_t("auth|uia|mas_cross_signing_reset_cta")}
</Button>
<Button onClick={this.onRetryClick} kind="secondary" className="mx_Dialog_nonDialogButton">
{_t("action|retry")}
</Button>
</Flex>
</div>
);
}
}
export interface IStageComponentProps extends IAuthEntryProps { export interface IStageComponentProps extends IAuthEntryProps {
stageParams?: Record<string, any>; stageParams?: Record<string, any>;
inputs?: IInputs; inputs?: IInputs;
@ -983,8 +1030,10 @@ export interface IStageComponent extends React.ComponentClass<React.PropsWithRef
focus?(): void; focus?(): void;
} }
export default function getEntryComponentForLoginType(loginType: AuthType): IStageComponent { export default function getEntryComponentForLoginType(loginType: AuthType | CustomAuthType): IStageComponent {
switch (loginType) { switch (loginType) {
case CustomAuthType.MasCrossSigningReset:
return MasUnlockCrossSigningAuthEntry;
case AuthType.Password: case AuthType.Password:
return PasswordAuthEntry; return PasswordAuthEntry;
case AuthType.Recaptcha: case AuthType.Recaptcha:

View file

@ -26,7 +26,6 @@ import { EchoChamber } from "../../../stores/local-echo/EchoChamber";
import { RoomNotifState } from "../../../RoomNotifs"; import { RoomNotifState } from "../../../RoomNotifs";
import Modal from "../../../Modal"; import Modal from "../../../Modal";
import ExportDialog from "../dialogs/ExportDialog"; import ExportDialog from "../dialogs/ExportDialog";
import { useFeatureEnabled } from "../../../hooks/useSettings";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog"; import { RoomSettingsTab } from "../dialogs/RoomSettingsDialog";
import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import { useEventEmitterState } from "../../../hooks/useEventEmitter";
@ -261,11 +260,10 @@ const RoomContextMenu: React.FC<IProps> = ({ room, onFinished, ...props }) => {
); );
} }
const pinningEnabled = useFeatureEnabled("feature_pinning"); const pinCount = usePinnedEvents(room).length;
const pinCount = usePinnedEvents(pinningEnabled ? room : undefined)?.length;
let pinsOption: JSX.Element | undefined; let pinsOption: JSX.Element | undefined;
if (pinningEnabled && !isVideoRoom) { if (!isVideoRoom) {
pinsOption = ( pinsOption = (
<IconizedContextMenuOption <IconizedContextMenuOption
onClick={(ev: ButtonEvent) => { onClick={(ev: ButtonEvent) => {

View file

@ -21,7 +21,6 @@ import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePha
import { ActionPayload } from "../../../dispatcher/payloads"; import { ActionPayload } from "../../../dispatcher/payloads";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { showThreadPanel } from "../../../dispatcher/dispatch-actions/threads"; import { showThreadPanel } from "../../../dispatcher/dispatch-actions/threads";
import SettingsStore from "../../../settings/SettingsStore";
import { import {
RoomNotificationStateStore, RoomNotificationStateStore,
UPDATE_STATUS_INDICATOR, UPDATE_STATUS_INDICATOR,
@ -245,17 +244,16 @@ export default class LegacyRoomHeaderButtons extends HeaderButtons<IProps> {
const rightPanelPhaseButtons: Map<RightPanelPhases, any> = new Map(); const rightPanelPhaseButtons: Map<RightPanelPhases, any> = new Map();
if (SettingsStore.getValue("feature_pinning")) { rightPanelPhaseButtons.set(
rightPanelPhaseButtons.set( RightPanelPhases.PinnedMessages,
RightPanelPhases.PinnedMessages, <PinnedMessagesHeaderButton
<PinnedMessagesHeaderButton key="pinnedMessagesButton"
key="pinnedMessagesButton" room={this.props.room}
room={this.props.room} isHighlighted={this.isPhase(RightPanelPhases.PinnedMessages)}
isHighlighted={this.isPhase(RightPanelPhases.PinnedMessages)} onClick={this.onPinnedMessagesClicked}
onClick={this.onPinnedMessagesClicked} />,
/>, );
);
}
rightPanelPhaseButtons.set( rightPanelPhaseButtons.set(
RightPanelPhases.Timeline, RightPanelPhases.Timeline,
<TimelineCardHeaderButton <TimelineCardHeaderButton

View file

@ -49,7 +49,6 @@ import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import { E2EStatus } from "../../../utils/ShieldUtils"; import { E2EStatus } from "../../../utils/ShieldUtils";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import { useFeatureEnabled } from "../../../hooks/useSettings";
import RoomName from "../elements/RoomName"; import RoomName from "../elements/RoomName";
import ExportDialog from "../dialogs/ExportDialog"; import ExportDialog from "../dialogs/ExportDialog";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
@ -73,6 +72,7 @@ import { Key } from "../../../Keyboard";
import { useTransition } from "../../../hooks/useTransition"; import { useTransition } from "../../../hooks/useTransition";
import { useIsVideoRoom } from "../../../utils/video-rooms"; import { useIsVideoRoom } from "../../../utils/video-rooms";
import { usePinnedEvents } from "../../../hooks/usePinnedEvents"; import { usePinnedEvents } from "../../../hooks/usePinnedEvents";
import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement.tsx";
interface IProps { interface IProps {
room: Room; room: Room;
@ -314,8 +314,7 @@ const RoomSummaryCard: React.FC<IProps> = ({
</header> </header>
); );
const pinningEnabled = useFeatureEnabled("feature_pinning"); const pinCount = usePinnedEvents(room).length;
const pinCount = usePinnedEvents(pinningEnabled ? room : undefined)?.length;
const roomTags = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () => const roomTags = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () =>
RoomListStore.instance.getTagsForRoom(room), RoomListStore.instance.getTagsForRoom(room),
@ -382,17 +381,25 @@ const RoomSummaryCard: React.FC<IProps> = ({
{!isVideoRoom && ( {!isVideoRoom && (
<> <>
{pinningEnabled && ( <ReleaseAnnouncement
<MenuItem feature="pinningMessageList"
Icon={PinIcon} header={_t("right_panel|pinned_messages|release_announcement|title")}
label={_t("right_panel|pinned_messages_button")} description={_t("right_panel|pinned_messages|release_announcement|description")}
onSelect={onRoomPinsClick} closeLabel={_t("right_panel|pinned_messages|release_announcement|close")}
> placement="top"
<Text as="span" size="sm"> >
{pinCount} <div>
</Text> <MenuItem
</MenuItem> Icon={PinIcon}
)} label={_t("right_panel|pinned_messages_button")}
onSelect={onRoomPinsClick}
>
<Text as="span" size="sm">
{pinCount}
</Text>
</MenuItem>
</div>
</ReleaseAnnouncement>
<MenuItem Icon={FilesIcon} label={_t("right_panel|files_button")} onSelect={onRoomFilesClick} /> <MenuItem Icon={FilesIcon} label={_t("right_panel|files_button")} onSelect={onRoomFilesClick} />
</> </>
)} )}

View file

@ -267,15 +267,13 @@ export default class RolesRoomSettingsTab extends React.Component<IProps> {
[EventType.RoomServerAcl]: _td("room_settings|permissions|m.room.server_acl"), [EventType.RoomServerAcl]: _td("room_settings|permissions|m.room.server_acl"),
[EventType.Reaction]: _td("room_settings|permissions|m.reaction"), [EventType.Reaction]: _td("room_settings|permissions|m.reaction"),
[EventType.RoomRedaction]: _td("room_settings|permissions|m.room.redaction"), [EventType.RoomRedaction]: _td("room_settings|permissions|m.room.redaction"),
[EventType.RoomPinnedEvents]: _td("room_settings|permissions|m.room.pinned_events"),
// TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111) // TODO: Enable support for m.widget event type (https://github.com/vector-im/element-web/issues/13111)
"im.vector.modular.widgets": isSpaceRoom ? null : _td("room_settings|permissions|m.widget"), "im.vector.modular.widgets": isSpaceRoom ? null : _td("room_settings|permissions|m.widget"),
[VoiceBroadcastInfoEventType]: _td("room_settings|permissions|io.element.voice_broadcast_info"), [VoiceBroadcastInfoEventType]: _td("room_settings|permissions|io.element.voice_broadcast_info"),
}; };
if (SettingsStore.getValue("feature_pinning")) {
plEventsToLabels[EventType.RoomPinnedEvents] = _td("room_settings|permissions|m.room.pinned_events");
}
// MSC3401: Native Group VoIP signaling // MSC3401: Native Group VoIP signaling
if (SettingsStore.getValue("feature_group_calls")) { if (SettingsStore.getValue("feature_group_calls")) {
plEventsToLabels[ElementCall.CALL_EVENT_TYPE.name] = _td("room_settings|permissions|m.call"); plEventsToLabels[ElementCall.CALL_EVENT_TYPE.name] = _td("room_settings|permissions|m.call");

View file

@ -29,7 +29,7 @@ import { MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
*/ */
export const useUserTimezone = (cli: MatrixClient, userId: string): { timezone: string; friendly: string } | null => { export const useUserTimezone = (cli: MatrixClient, userId: string): { timezone: string; friendly: string } | null => {
const [timezone, setTimezone] = useState<string>(); const [timezone, setTimezone] = useState<string>();
const [updateInterval, setUpdateInterval] = useState<number>(); const [updateInterval, setUpdateInterval] = useState<ReturnType<typeof setTimeout>>();
const [friendly, setFriendly] = useState<string>(); const [friendly, setFriendly] = useState<string>();
const [supported, setSupported] = useState<boolean>(); const [supported, setSupported] = useState<boolean>();

View file

@ -229,6 +229,7 @@
}, },
"misconfigured_body": "Ask your %(brand)s admin to check <a>your config</a> for incorrect or duplicate entries.", "misconfigured_body": "Ask your %(brand)s admin to check <a>your config</a> for incorrect or duplicate entries.",
"misconfigured_title": "Your %(brand)s is misconfigured", "misconfigured_title": "Your %(brand)s is misconfigured",
"mobile_create_account_title": "You're about to create an account on %(hsName)s",
"msisdn_field_description": "Other users can invite you to rooms using your contact details", "msisdn_field_description": "Other users can invite you to rooms using your contact details",
"msisdn_field_label": "Phone", "msisdn_field_label": "Phone",
"msisdn_field_number_invalid": "That phone number doesn't look quite right, please check and try again", "msisdn_field_number_invalid": "That phone number doesn't look quite right, please check and try again",
@ -369,6 +370,8 @@
"email_resend_prompt": "Did not receive it? <a>Resend it</a>", "email_resend_prompt": "Did not receive it? <a>Resend it</a>",
"email_resent": "Resent!", "email_resent": "Resent!",
"fallback_button": "Start authentication", "fallback_button": "Start authentication",
"mas_cross_signing_reset_cta": "Go to your account",
"mas_cross_signing_reset_description": "Reset your identity through your account provider and then come back and click “Retry”.",
"msisdn": "A text message has been sent to %(msisdn)s", "msisdn": "A text message has been sent to %(msisdn)s",
"msisdn_token_incorrect": "Token incorrect", "msisdn_token_incorrect": "Token incorrect",
"msisdn_token_prompt": "Please enter the code it contains:", "msisdn_token_prompt": "Please enter the code it contains:",
@ -1465,7 +1468,6 @@
"notifications": "Enable the notifications panel in the room header", "notifications": "Enable the notifications panel in the room header",
"oidc_native_flow": "OIDC native authentication", "oidc_native_flow": "OIDC native authentication",
"oidc_native_flow_description": "⚠ WARNING: Experimental. Use OIDC native authentication when supported by the server.", "oidc_native_flow_description": "⚠ WARNING: Experimental. Use OIDC native authentication when supported by the server.",
"pinning": "Message Pinning",
"release_announcement": "Release announcement", "release_announcement": "Release announcement",
"render_reaction_images": "Render custom images in reactions", "render_reaction_images": "Render custom images in reactions",
"render_reaction_images_description": "Sometimes referred to as \"custom emojis\".", "render_reaction_images_description": "Sometimes referred to as \"custom emojis\".",
@ -1851,6 +1853,11 @@
"other": "You can only pin up to %(count)s widgets" "other": "You can only pin up to %(count)s widgets"
}, },
"menu": "Open menu", "menu": "Open menu",
"release_announcement": {
"close": "Ok",
"description": "Find all pinned messages here. Rollover any message and select “Pin” to add it.",
"title": "All new pinned messages"
},
"reply_thread": "Reply to a <link>thread message</link>", "reply_thread": "Reply to a <link>thread message</link>",
"title": "Pinned messages", "title": "Pinned messages",
"unpin_all": { "unpin_all": {

View file

@ -275,14 +275,6 @@ export const SETTINGS: { [setting: string]: ISetting } = {
supportedLevelsAreOrdered: true, supportedLevelsAreOrdered: true,
default: false, default: false,
}, },
"feature_pinning": {
isFeature: true,
labsGroup: LabGroup.Messaging,
displayName: _td("labs|pinning"),
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED,
supportedLevelsAreOrdered: true,
default: true,
},
"feature_wysiwyg_composer": { "feature_wysiwyg_composer": {
isFeature: true, isFeature: true,
labsGroup: LabGroup.Messaging, labsGroup: LabGroup.Messaging,
@ -876,6 +868,10 @@ export const SETTINGS: { [setting: string]: ISetting } = {
supportedLevels: LEVELS_ACCOUNT_SETTINGS, supportedLevels: LEVELS_ACCOUNT_SETTINGS,
default: null, default: null,
}, },
"Registration.mobileRegistrationHelper": {
supportedLevels: [SettingLevel.CONFIG],
default: false,
},
"autocompleteDelay": { "autocompleteDelay": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG, supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
default: 200, default: 200,

View file

@ -17,7 +17,7 @@ import { Features } from "../settings/Settings";
/** /**
* The features are shown in the array order. * The features are shown in the array order.
*/ */
const FEATURES = ["threadsActivityCentre"] as const; const FEATURES = ["threadsActivityCentre", "pinningMessageList"] as const;
/** /**
* All the features that can be shown in the release announcements. * All the features that can be shown in the release announcements.
*/ */

View file

@ -9,7 +9,6 @@ Please see LICENSE files in the repository root for full details.
import { MatrixEvent, EventType, M_POLL_START, MatrixClient, EventTimeline, Room } from "matrix-js-sdk/src/matrix"; import { MatrixEvent, EventType, M_POLL_START, MatrixClient, EventTimeline, Room } from "matrix-js-sdk/src/matrix";
import { isContentActionable } from "./EventUtils"; import { isContentActionable } from "./EventUtils";
import SettingsStore from "../settings/SettingsStore";
import { ReadPinsEventId } from "../components/views/right_panel/types"; import { ReadPinsEventId } from "../components/views/right_panel/types";
export default class PinningUtils { export default class PinningUtils {
@ -70,7 +69,6 @@ export default class PinningUtils {
* @private * @private
*/ */
private static canPinOrUnpin(matrixClient: MatrixClient, mxEvent: MatrixEvent): boolean { private static canPinOrUnpin(matrixClient: MatrixClient, mxEvent: MatrixEvent): boolean {
if (!SettingsStore.getValue("feature_pinning")) return false;
if (!isContentActionable(mxEvent)) return false; if (!isContentActionable(mxEvent)) return false;
const room = matrixClient.getRoom(mxEvent.getRoomId()); const room = matrixClient.getRoom(mxEvent.getRoomId());

View file

@ -65,11 +65,6 @@ describe("TextForEvent", () => {
}); });
describe("TextForPinnedEvent", () => { describe("TextForPinnedEvent", () => {
beforeAll(() => {
// enable feature_pinning setting
(SettingsStore.getValue as jest.Mock).mockImplementation((feature) => feature === "feature_pinning");
});
it("mentions message when a single message was pinned, with no previously pinned messages", () => { it("mentions message when a single message was pinned, with no previously pinned messages", () => {
const event = mockPinnedEvent(["message-1"]); const event = mockPinnedEvent(["message-1"]);
const plainText = textForEvent(event, mockClient); const plainText = textForEvent(event, mockClient);

View file

@ -7,11 +7,14 @@
*/ */
import React from "react"; import React from "react";
import { render, screen, waitFor, act } from "@testing-library/react"; import { render, screen, waitFor, act, fireEvent } from "@testing-library/react";
import { AuthType } from "matrix-js-sdk/src/interactive-auth"; import { AuthType } from "matrix-js-sdk/src/interactive-auth";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { EmailIdentityAuthEntry } from "../../../../src/components/views/auth/InteractiveAuthEntryComponents"; import {
EmailIdentityAuthEntry,
MasUnlockCrossSigningAuthEntry,
} from "../../../../src/components/views/auth/InteractiveAuthEntryComponents";
import { createTestClient } from "../../../test-utils"; import { createTestClient } from "../../../test-utils";
describe("<EmailIdentityAuthEntry/>", () => { describe("<EmailIdentityAuthEntry/>", () => {
@ -55,3 +58,44 @@ describe("<EmailIdentityAuthEntry/>", () => {
await waitFor(() => expect(screen.queryByRole("button", { name: "Resend" })).toBeInTheDocument()); await waitFor(() => expect(screen.queryByRole("button", { name: "Resend" })).toBeInTheDocument());
}); });
}); });
describe("<MasUnlockCrossSigningAuthEntry/>", () => {
const renderAuth = (props = {}) => {
const matrixClient = createTestClient();
return render(
<MasUnlockCrossSigningAuthEntry
matrixClient={matrixClient}
loginType={AuthType.Email}
onPhaseChange={jest.fn()}
submitAuthDict={jest.fn()}
fail={jest.fn()}
clientSecret="my secret"
showContinue={true}
stageParams={{ url: "https://example.com" }}
{...props}
/>,
);
};
test("should render", () => {
const { container } = renderAuth();
expect(container).toMatchSnapshot();
});
test("should open idp in new tab on click", async () => {
const spy = jest.spyOn(global.window, "open");
renderAuth();
fireEvent.click(screen.getByRole("button", { name: "Go to your account" }));
expect(spy).toHaveBeenCalledWith("https://example.com", "_blank");
});
test("should retry uia request on click", async () => {
const submitAuthDict = jest.fn();
renderAuth({ submitAuthDict });
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
expect(submitAuthDict).toHaveBeenCalledWith({});
});
});

View file

@ -32,3 +32,53 @@ exports[`<EmailIdentityAuthEntry/> should render 1`] = `
</div> </div>
</div> </div>
`; `;
exports[`<MasUnlockCrossSigningAuthEntry/> should render 1`] = `
<div>
<div>
<p
class="_typography_yh5dq_162 _font-body-md-regular_yh5dq_59"
>
Reset your identity through your account provider and then come back and click “Retry”.
</p>
<div
class="mx_Flex"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: var(--cpd-space-4x);"
>
<button
class="_button_zt6rp_17 mx_Dialog_nonDialogButton _has-icon_zt6rp_61"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
<svg
aria-hidden="true"
fill="currentColor"
height="20"
viewBox="0 0 24 24"
width="20"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 3h6a1 1 0 1 1 0 2H5v14h14v-6a1 1 0 1 1 2 0v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z"
/>
<path
d="M15 3h5a1 1 0 0 1 1 1v5a1 1 0 1 1-2 0V6.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L17.586 5H15a1 1 0 1 1 0-2Z"
/>
</svg>
Go to your account
</button>
<button
class="_button_zt6rp_17 mx_Dialog_nonDialogButton"
data-kind="secondary"
data-size="lg"
role="button"
tabindex="0"
>
Retry
</button>
</div>
</div>
</div>
`;

View file

@ -116,22 +116,6 @@ describe("MessageContextMenu", () => {
expect(screen.queryByRole("menuitem", { name: "Pin" })).toBeFalsy(); expect(screen.queryByRole("menuitem", { name: "Pin" })).toBeFalsy();
}); });
it("does not show pin option when pinning feature is disabled", () => {
const eventContent = createMessageEventContent("hello");
const pinnableEvent = new MatrixEvent({
type: EventType.RoomMessage,
content: eventContent,
room_id: roomId,
});
// disable pinning feature
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
createMenu(pinnableEvent, { rightClick: true }, {}, undefined, room);
expect(screen.queryByRole("menuitem", { name: "Pin" })).toBeFalsy();
});
it("shows pin option when pinning feature is enabled", () => { it("shows pin option when pinning feature is enabled", () => {
const eventContent = createMessageEventContent("hello"); const eventContent = createMessageEventContent("hello");
const pinnableEvent = new MatrixEvent({ const pinnableEvent = new MatrixEvent({

View file

@ -259,8 +259,7 @@ describe("<RoomSummaryCard />", () => {
}); });
describe("pinning", () => { describe("pinning", () => {
it("renders pins options when pinning feature is enabled", () => { it("renders pins options", () => {
mocked(settingsHooks.useFeatureEnabled).mockImplementation((feature) => feature === "feature_pinning");
const { getByText } = getComponent(); const { getByText } = getComponent();
expect(getByText("Pinned messages")).toBeInTheDocument(); expect(getByText("Pinned messages")).toBeInTheDocument();
@ -291,9 +290,7 @@ describe("<RoomSummaryCard />", () => {
describe("video rooms", () => { describe("video rooms", () => {
it("does not render irrelevant options for element video room", () => { it("does not render irrelevant options for element video room", () => {
jest.spyOn(room, "isElementVideoRoom").mockReturnValue(true); jest.spyOn(room, "isElementVideoRoom").mockReturnValue(true);
mocked(settingsHooks.useFeatureEnabled).mockImplementation( mocked(settingsHooks.useFeatureEnabled).mockImplementation((feature) => feature === "feature_video_rooms");
(feature) => feature === "feature_video_rooms" || feature === "feature_pinning",
);
const { queryByText } = getComponent(); const { queryByText } = getComponent();
// options not rendered // options not rendered
@ -305,10 +302,7 @@ describe("<RoomSummaryCard />", () => {
it("does not render irrelevant options for element call room", () => { it("does not render irrelevant options for element call room", () => {
jest.spyOn(room, "isCallRoom").mockReturnValue(true); jest.spyOn(room, "isCallRoom").mockReturnValue(true);
mocked(settingsHooks.useFeatureEnabled).mockImplementation( mocked(settingsHooks.useFeatureEnabled).mockImplementation(
(feature) => (feature) => feature === "feature_element_call_video_rooms" || feature === "feature_video_rooms",
feature === "feature_element_call_video_rooms" ||
feature === "feature_video_rooms" ||
feature === "feature_pinning",
); );
const { queryByText } = getComponent(); const { queryByText } = getComponent();

View file

@ -186,6 +186,55 @@ exports[`<RoomSummaryCard /> has button to edit topic 1`] = `
data-orientation="horizontal" data-orientation="horizontal"
role="separator" role="separator"
/> />
<div
aria-expanded="false"
aria-haspopup="dialog"
>
<button
class="_item_1gwvj_17 _interactive_1gwvj_36"
data-kind="primary"
role="menuitem"
>
<svg
aria-hidden="true"
class="_icon_1gwvj_44"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M6.119 2a.5.5 0 0 0-.35.857L7.85 4.9a.5.5 0 0 1 .15.357v4.487a.5.5 0 0 1-.15.356l-3.7 3.644A.5.5 0 0 0 4 14.1v1.4a.5.5 0 0 0 .5.5H11v6a1 1 0 1 0 2 0v-6h6.5a.5.5 0 0 0 .5-.5v-1.4a.5.5 0 0 0-.15-.356l-3.7-3.644a.5.5 0 0 1-.15-.356V5.257a.5.5 0 0 1 .15-.357l2.081-2.043a.5.5 0 0 0-.35-.857H6.119ZM10 4h4v5.744a2.5 2.5 0 0 0 .746 1.781L17.26 14H6.74l2.514-2.475A2.5 2.5 0 0 0 10 9.744V4Z"
fill-rule="evenodd"
/>
</svg>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
>
Pinned messages
</span>
<svg
aria-hidden="true"
class="_nav-hint_1gwvj_60"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
/>
</svg>
<span
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40"
>
0
</span>
</button>
</div>
<button <button
class="_item_1gwvj_17 _interactive_1gwvj_36" class="_item_1gwvj_17 _interactive_1gwvj_36"
data-kind="primary" data-kind="primary"
@ -584,6 +633,55 @@ exports[`<RoomSummaryCard /> renders the room summary 1`] = `
data-orientation="horizontal" data-orientation="horizontal"
role="separator" role="separator"
/> />
<div
aria-expanded="false"
aria-haspopup="dialog"
>
<button
class="_item_1gwvj_17 _interactive_1gwvj_36"
data-kind="primary"
role="menuitem"
>
<svg
aria-hidden="true"
class="_icon_1gwvj_44"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M6.119 2a.5.5 0 0 0-.35.857L7.85 4.9a.5.5 0 0 1 .15.357v4.487a.5.5 0 0 1-.15.356l-3.7 3.644A.5.5 0 0 0 4 14.1v1.4a.5.5 0 0 0 .5.5H11v6a1 1 0 1 0 2 0v-6h6.5a.5.5 0 0 0 .5-.5v-1.4a.5.5 0 0 0-.15-.356l-3.7-3.644a.5.5 0 0 1-.15-.356V5.257a.5.5 0 0 1 .15-.357l2.081-2.043a.5.5 0 0 0-.35-.857H6.119ZM10 4h4v5.744a2.5 2.5 0 0 0 .746 1.781L17.26 14H6.74l2.514-2.475A2.5 2.5 0 0 0 10 9.744V4Z"
fill-rule="evenodd"
/>
</svg>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
>
Pinned messages
</span>
<svg
aria-hidden="true"
class="_nav-hint_1gwvj_60"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
/>
</svg>
<span
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40"
>
0
</span>
</button>
</div>
<button <button
class="_item_1gwvj_17 _interactive_1gwvj_36" class="_item_1gwvj_17 _interactive_1gwvj_36"
data-kind="primary" data-kind="primary"
@ -1009,6 +1107,55 @@ exports[`<RoomSummaryCard /> renders the room topic in the summary 1`] = `
data-orientation="horizontal" data-orientation="horizontal"
role="separator" role="separator"
/> />
<div
aria-expanded="false"
aria-haspopup="dialog"
>
<button
class="_item_1gwvj_17 _interactive_1gwvj_36"
data-kind="primary"
role="menuitem"
>
<svg
aria-hidden="true"
class="_icon_1gwvj_44"
fill="currentColor"
height="24"
viewBox="0 0 24 24"
width="24"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M6.119 2a.5.5 0 0 0-.35.857L7.85 4.9a.5.5 0 0 1 .15.357v4.487a.5.5 0 0 1-.15.356l-3.7 3.644A.5.5 0 0 0 4 14.1v1.4a.5.5 0 0 0 .5.5H11v6a1 1 0 1 0 2 0v-6h6.5a.5.5 0 0 0 .5-.5v-1.4a.5.5 0 0 0-.15-.356l-3.7-3.644a.5.5 0 0 1-.15-.356V5.257a.5.5 0 0 1 .15-.357l2.081-2.043a.5.5 0 0 0-.35-.857H6.119ZM10 4h4v5.744a2.5 2.5 0 0 0 .746 1.781L17.26 14H6.74l2.514-2.475A2.5 2.5 0 0 0 10 9.744V4Z"
fill-rule="evenodd"
/>
</svg>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
>
Pinned messages
</span>
<svg
aria-hidden="true"
class="_nav-hint_1gwvj_60"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
/>
</svg>
<span
class="_typography_yh5dq_162 _font-body-sm-regular_yh5dq_40"
>
0
</span>
</button>
</div>
<button <button
class="_item_1gwvj_17 _interactive_1gwvj_36" class="_item_1gwvj_17 _interactive_1gwvj_36"
data-kind="primary" data-kind="primary"

View file

@ -19,7 +19,6 @@ import dis from "../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../src/dispatcher/actions"; import { Action } from "../../../../src/dispatcher/actions";
import { getForwardableEvent } from "../../../../src/events"; import { getForwardableEvent } from "../../../../src/events";
import { createRedactEventDialog } from "../../../../src/components/views/dialogs/ConfirmRedactDialog"; import { createRedactEventDialog } from "../../../../src/components/views/dialogs/ConfirmRedactDialog";
import SettingsStore from "../../../../src/settings/SettingsStore.ts";
jest.mock("../../../../src/components/views/dialogs/ConfirmRedactDialog", () => ({ jest.mock("../../../../src/components/views/dialogs/ConfirmRedactDialog", () => ({
createRedactEventDialog: jest.fn(), createRedactEventDialog: jest.fn(),
@ -38,8 +37,6 @@ describe("<PinnedEventTile />", () => {
permalinkCreator = new RoomPermalinkCreator(room); permalinkCreator = new RoomPermalinkCreator(room);
mockClient.getRoom = jest.fn().mockReturnValue(room); mockClient.getRoom = jest.fn().mockReturnValue(room);
jest.spyOn(dis, "dispatch").mockReturnValue(undefined); jest.spyOn(dis, "dispatch").mockReturnValue(undefined);
// Enable feature_pinning
jest.spyOn(SettingsStore, "getValue").mockReturnValue(true);
}); });
/** /**

View file

@ -89,7 +89,13 @@ describe("ReleaseAnnouncementStore", () => {
// Sanity check // Sanity check
expect(releaseAnnouncementStore.getReleaseAnnouncement()).toBe("threadsActivityCentre"); expect(releaseAnnouncementStore.getReleaseAnnouncement()).toBe("threadsActivityCentre");
const promise = listenReleaseAnnouncementChanged(); let promise = listenReleaseAnnouncementChanged();
await releaseAnnouncementStore.nextReleaseAnnouncement();
expect(await promise).toBe("pinningMessageList");
expect(releaseAnnouncementStore.getReleaseAnnouncement()).toBe("pinningMessageList");
promise = listenReleaseAnnouncementChanged();
await releaseAnnouncementStore.nextReleaseAnnouncement(); await releaseAnnouncementStore.nextReleaseAnnouncement();
expect(await promise).toBeNull(); expect(await promise).toBeNull();
@ -108,7 +114,7 @@ describe("ReleaseAnnouncementStore", () => {
const promise = listenReleaseAnnouncementChanged(); const promise = listenReleaseAnnouncementChanged();
await secondStore.nextReleaseAnnouncement(); await secondStore.nextReleaseAnnouncement();
expect(await promise).toBeNull(); expect(await promise).toBe("pinningMessageList");
expect(releaseAnnouncementStore.getReleaseAnnouncement()).toBeNull(); expect(releaseAnnouncementStore.getReleaseAnnouncement()).toBe("pinningMessageList");
}); });
}); });

View file

@ -376,8 +376,8 @@ describe("Rageshakes", () => {
const mockSettingsStore = mocked(SettingsStore); const mockSettingsStore = mocked(SettingsStore);
it("should collect labs from settings store", async () => { it("should collect labs from settings store", async () => {
const someFeatures: string[] = ["feature_video_rooms", "feature_notification_settings2", "feature_pinning"]; const someFeatures: string[] = ["feature_video_rooms", "feature_notification_settings2"];
const enabledFeatures: string[] = ["feature_video_rooms", "feature_pinning"]; const enabledFeatures: string[] = ["feature_video_rooms"];
jest.spyOn(mockSettingsStore, "getFeatureSettingNames").mockReturnValue(someFeatures); jest.spyOn(mockSettingsStore, "getFeatureSettingNames").mockReturnValue(someFeatures);
jest.spyOn(mockSettingsStore, "getValue").mockImplementation((settingName): any => { jest.spyOn(mockSettingsStore, "getValue").mockImplementation((settingName): any => {
return enabledFeatures.includes(settingName); return enabledFeatures.includes(settingName);

View file

@ -141,14 +141,6 @@ describe("PinningUtils", () => {
describe("canPin & canUnpin", () => { describe("canPin & canUnpin", () => {
describe("canPin", () => { describe("canPin", () => {
test("should return false if pinning is disabled", () => {
// Disable feature pinning
jest.spyOn(SettingsStore, "getValue").mockReturnValue(false);
const event = makePinEvent();
expect(PinningUtils.canPin(matrixClient, event)).toBe(false);
});
test("should return false if event is not actionable", () => { test("should return false if event is not actionable", () => {
mockedIsContentActionable.mockImplementation(() => false); mockedIsContentActionable.mockImplementation(() => false);
const event = makePinEvent(); const event = makePinEvent();