diff --git a/playwright/e2e/crypto/event-shields.spec.ts b/playwright/e2e/crypto/event-shields.spec.ts index 077d9126fa..3a85d06333 100644 --- a/playwright/e2e/crypto/event-shields.spec.ts +++ b/playwright/e2e/crypto/event-shields.spec.ts @@ -41,7 +41,12 @@ 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 const bobSecondDevice = new Bot(page, homeserver, { bootstrapSecretStorage: false, @@ -117,7 +122,10 @@ test.describe("Cryptography", function () { await lastTileE2eIcon.focus(); 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 await bobSecondDevice.evaluate((cli) => cli.logout(true)); @@ -148,7 +156,11 @@ test.describe("Cryptography", function () { await expect(last).toContainText("test encrypted from unverified"); await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); 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 ({ diff --git a/playwright/plugins/homeserver/synapse/index.ts b/playwright/plugins/homeserver/synapse/index.ts index a2d8138b52..becfffaa19 100644 --- a/playwright/plugins/homeserver/synapse/index.ts +++ b/playwright/plugins/homeserver/synapse/index.ts @@ -20,7 +20,7 @@ import { randB64Bytes } from "../../utils/rand"; // 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. // This digest is updated by the playwright-image-updates.yaml workflow periodically. -const DOCKER_TAG = "develop@sha256:5f8d9e0d8c34dd3af23a3b8f2d30223710bccd657f86384803ce4c1cf2fa7263"; +const DOCKER_TAG = "develop@sha256:e69f01d085a69269c892dfa899cb274a593f0fbb4c518eac2b530319fa43c7cb"; async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise> { const templateDir = path.join(__dirname, "templates", opts.template); diff --git a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png index 9d47c993a4..e3e54f11e5 100644 Binary files a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png and b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png differ diff --git a/res/css/views/right_panel/_UserInfo.pcss b/res/css/views/right_panel/_UserInfo.pcss index e7dd5eb2f5..0deb3d3708 100644 --- a/res/css/views/right_panel/_UserInfo.pcss +++ b/res/css/views/right_panel/_UserInfo.pcss @@ -124,6 +124,10 @@ Please see LICENSE files in the repository root for full details. } } + .mx_UserInfo_timezone { + margin: var(--cpd-space-1x) 0; + } + .mx_PresenceLabel { font: var(--cpd-font-body-sm-regular); opacity: 1; diff --git a/src/components/structures/InteractiveAuth.tsx b/src/components/structures/InteractiveAuth.tsx index 86cf6af665..91e52a1905 100644 --- a/src/components/structures/InteractiveAuth.tsx +++ b/src/components/structures/InteractiveAuth.tsx @@ -20,6 +20,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import getEntryComponentForLoginType, { ContinueKind, + CustomAuthType, IStageComponent, } from "../views/auth/InteractiveAuthEntryComponents"; import Spinner from "../views/elements/Spinner"; @@ -75,11 +76,11 @@ export interface InteractiveAuthProps { // 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 // 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 { - authStage?: AuthType; + authStage?: CustomAuthType | AuthType; stageState?: IStageStatus; busy: boolean; errorText?: string; diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index 929de80437..84c43fc19d 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -131,6 +131,7 @@ class LoggedInView extends React.Component { protected layoutWatcherRef?: string; protected compactLayoutWatcherRef?: string; protected backgroundImageWatcherRef?: string; + protected timezoneProfileUpdateRef?: string[]; protected resizer?: Resizer; public constructor(props: IProps) { @@ -182,6 +183,11 @@ class LoggedInView extends React.Component { this.refreshBackgroundImage, ); + this.timezoneProfileUpdateRef = [ + SettingsStore.watchSetting("userTimezonePublish", null, this.onTimezoneUpdate), + SettingsStore.watchSetting("userTimezone", null, this.onTimezoneUpdate), + ]; + this.resizer = this.createResizer(); this.resizer.attach(); @@ -190,6 +196,31 @@ class LoggedInView extends React.Component { this.refreshBackgroundImage(); } + private onTimezoneUpdate = async (): Promise => { + if (!SettingsStore.getValue("userTimezonePublish")) { + // Ensure it's deleted + try { + await this._matrixClient.deleteExtendedProfileProperty("us.cloke.msc4175.tz"); + } catch (ex) { + console.warn("Failed to delete timezone from user profile", ex); + } + return; + } + const currentTimezone = + SettingsStore.getValue("userTimezone") || + // If the timezone is empty, then use the browser timezone. + // eslint-disable-next-line new-cap + Intl.DateTimeFormat().resolvedOptions().timeZone; + if (!currentTimezone || typeof currentTimezone !== "string") { + return; + } + try { + await this._matrixClient.setExtendedProfileProperty("us.cloke.msc4175.tz", currentTimezone); + } catch (ex) { + console.warn("Failed to update user profile with current timezone", ex); + } + }; + public componentWillUnmount(): void { document.removeEventListener("keydown", this.onNativeKeyDown, false); LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.onCallState); @@ -200,6 +231,7 @@ class LoggedInView extends React.Component { if (this.layoutWatcherRef) SettingsStore.unwatchSetting(this.layoutWatcherRef); if (this.compactLayoutWatcherRef) SettingsStore.unwatchSetting(this.compactLayoutWatcherRef); if (this.backgroundImageWatcherRef) SettingsStore.unwatchSetting(this.backgroundImageWatcherRef); + this.timezoneProfileUpdateRef?.forEach((s) => SettingsStore.unwatchSetting(s)); this.resizer?.detach(); } diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index a0946564aa..7a15ee2095 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -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 { logger } from "matrix-js-sdk/src/logger"; 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 { _t } from "../../../languageHandler"; @@ -21,6 +23,7 @@ import AccessibleButton, { AccessibleButtonKind, ButtonEvent } from "../elements import Field from "../elements/Field"; import Spinner from "../elements/Spinner"; import CaptchaForm from "./CaptchaForm"; +import { Flex } from "../../utils/Flex"; /* This file contains a collection of components which are used by the * InteractiveAuth to prompt the user to enter the information needed @@ -905,11 +908,11 @@ export class SSOAuthEntry extends React.Component { - private popupWindow: Window | null; - private fallbackButton = createRef(); +export class FallbackAuthEntry extends React.Component { + protected popupWindow: Window | null; + protected fallbackButton = createRef(); - public constructor(props: IAuthEntryProps) { + public constructor(props: IAuthEntryProps & T) { super(props); // we have to make the user click a button, as browsers will block @@ -967,6 +970,50 @@ export class FallbackAuthEntry extends React.Component { } } +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 ( +
+ {_t("auth|uia|mas_cross_signing_reset_description")} + + + + +
+ ); + } +} + export interface IStageComponentProps extends IAuthEntryProps { stageParams?: Record; inputs?: IInputs; @@ -983,8 +1030,10 @@ export interface IStageComponent extends React.ComponentClass : null; const userIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, { roomId, @@ -1727,6 +1729,15 @@ export const UserInfoHeader: React.FC<{ {presenceLabel} + {timezoneInfo && ( + + + + {timezoneInfo?.friendly ?? ""} + + + + )} userIdentifier} border={false}> {userIdentifier} diff --git a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx index 85b2bdfd5c..d95b0894d9 100644 --- a/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/PreferencesUserSettingsTab.tsx @@ -302,6 +302,7 @@ export default class PreferencesUserSettingsTab extends React.Component {this.renderGroup(PreferencesUserSettingsTab.TIME_SETTINGS)} + - Promise.all( - pinnedEventIds.map( - async (eventId): Promise => fetchPinnedEvent(room, eventId, cli), - ), - ), + () => { + const fetchPromises = pinnedEventIds.map((eventId) => () => fetchPinnedEvent(room, eventId, cli)); + // Fetch the pinned events in batches of 10 + return batch(fetchPromises, 10); + }, [cli, room, pinnedEventIds], null, ); diff --git a/src/hooks/useUserTimezone.ts b/src/hooks/useUserTimezone.ts new file mode 100644 index 0000000000..686679bb90 --- /dev/null +++ b/src/hooks/useUserTimezone.ts @@ -0,0 +1,106 @@ +/* +Copyright 2024 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +import { useEffect, useState } from "react"; +import { MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; + +/** + * Fetch a user's delclared timezone through their profile, and return + * a friendly string of the current time for that user. This will keep + * in sync with the current time, and will be refreshed once a minute. + * + * @param cli The Matrix Client instance. + * @param userId The userID to fetch the timezone for. + * @returns A timezone name and friendly string for the user's timezone, or + * null if the user has no timezone or the timezone was not recognised + * by the browser. + */ +export const useUserTimezone = (cli: MatrixClient, userId: string): { timezone: string; friendly: string } | null => { + const [timezone, setTimezone] = useState(); + const [updateInterval, setUpdateInterval] = useState>(); + const [friendly, setFriendly] = useState(); + const [supported, setSupported] = useState(); + + useEffect(() => { + if (!cli || supported !== undefined) { + return; + } + cli.doesServerSupportExtendedProfiles() + .then(setSupported) + .catch((ex) => { + console.warn("Unable to determine if extended profiles are supported", ex); + }); + }, [supported, cli]); + + useEffect(() => { + return () => { + if (updateInterval) { + clearInterval(updateInterval); + } + }; + }, [updateInterval]); + + useEffect(() => { + if (supported !== true) { + return; + } + (async () => { + console.log("Trying to fetch TZ"); + try { + const tz = await cli.getExtendedProfileProperty(userId, "us.cloke.msc4175.tz"); + if (typeof tz !== "string") { + // Err, definitely not a tz. + throw Error("Timezone value was not a string"); + } + // This will validate the timezone for us. + // eslint-disable-next-line new-cap + Intl.DateTimeFormat(undefined, { timeZone: tz }); + + const updateTime = (): void => { + const currentTime = new Date(); + const friendly = currentTime.toLocaleString(undefined, { + timeZone: tz, + hour12: true, + hour: "2-digit", + minute: "2-digit", + timeZoneName: "shortOffset", + }); + setTimezone(tz); + setFriendly(friendly); + setUpdateInterval(setTimeout(updateTime, (60 - currentTime.getSeconds()) * 1000)); + }; + updateTime(); + } catch (ex) { + setTimezone(undefined); + setFriendly(undefined); + setUpdateInterval(undefined); + if (ex instanceof MatrixError && ex.errcode === "M_NOT_FOUND") { + // No timezone set, ignore. + return; + } + console.error("Could not render current timezone for user", ex); + } + })(); + }, [supported, userId, cli]); + + if (!timezone || !friendly) { + return null; + } + + return { + friendly, + timezone, + }; +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 24de78a6cc..a936fac5fb 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -369,6 +369,8 @@ "email_resend_prompt": "Did not receive it? Resend it", "email_resent": "Resent!", "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_token_incorrect": "Token incorrect", "msisdn_token_prompt": "Please enter the code it contains:", @@ -1426,6 +1428,7 @@ "element_call_video_rooms": "Element Call video rooms", "experimental_description": "Feeling experimental? Try out our latest ideas in development. These features are not finalised; they may be unstable, may change, or may be dropped altogether. Learn more.", "experimental_section": "Early previews", + "extended_profiles_msc_support": "Requires your server to support MSC4133", "feature_disable_call_per_sender_encryption": "Disable per-sender encryption for Element Call", "feature_wysiwyg_composer_description": "Use rich text instead of Markdown in the message composer.", "group_calls": "New group call experience", @@ -2719,6 +2722,7 @@ "keyboard_view_shortcuts_button": "To view all keyboard shortcuts, click here.", "media_heading": "Images, GIFs and videos", "presence_description": "Share your activity and status with others.", + "publish_timezone": "Publish timezone on public profile", "rm_lifetime": "Read Marker lifetime (ms)", "rm_lifetime_offscreen": "Read Marker off-screen lifetime (ms)", "room_directory_heading": "Room directory", diff --git a/src/i18n/strings/pl.json b/src/i18n/strings/pl.json index b8cdf36676..7b14bbaf1d 100644 --- a/src/i18n/strings/pl.json +++ b/src/i18n/strings/pl.json @@ -1968,8 +1968,6 @@ "few": "%(count)s osoby proszą o dołączenie", "many": "%(count)s osób prosi o dołączenie" }, - "release_announcement_description": "Ciesz się prostszym, bardziej przystosowanym nagłówkiem pokoju.", - "release_announcement_header": "Nowy design!", "room_is_public": "Ten pokój jest publiczny", "show_widgets_button": "Pokaż widżety", "video_call_button_ec": "Rozmowa wideo (%(brand)s)", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index bf9ae417e2..af56d44f9c 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { ReactNode } from "react"; +import { UNSTABLE_MSC4133_EXTENDED_PROFILES } from "matrix-js-sdk/src/matrix"; import { _t, _td, TranslationKey } from "../languageHandler"; import { @@ -641,6 +642,19 @@ export const SETTINGS: { [setting: string]: ISetting } = { displayName: _td("settings|preferences|user_timezone"), default: "", }, + "userTimezonePublish": { + // This is per-device so you can avoid having devices overwrite each other. + supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS, + displayName: _td("settings|preferences|publish_timezone"), + default: false, + controller: new ServerSupportUnstableFeatureController( + "userTimezonePublish", + defaultWatchManager, + [[UNSTABLE_MSC4133_EXTENDED_PROFILES]], + undefined, + _td("labs|extended_profiles_msc_support"), + ), + }, "autoplayGifs": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td("settings|autoplay_gifs"), diff --git a/src/utils/promise.ts b/src/utils/promise.ts index bceb2cc3cc..58dfdc8cd9 100644 --- a/src/utils/promise.ts +++ b/src/utils/promise.ts @@ -40,3 +40,18 @@ export async function retry( } throw lastErr; } + +/** + * Batch promises into groups of a given size. + * Execute the promises in parallel, but wait for all promises in a batch to resolve before moving to the next batch. + * @param funcs - The promises to batch + * @param batchSize - The number of promises to execute in parallel + */ +export async function batch(funcs: Array<() => Promise>, batchSize: number): Promise { + const results: T[] = []; + for (let i = 0; i < funcs.length; i += batchSize) { + const batch = funcs.slice(i, i + batchSize); + results.push(...(await Promise.all(batch.map((f) => f())))); + } + return results; +} diff --git a/test/components/structures/LoggedInView-test.tsx b/test/components/structures/LoggedInView-test.tsx index 771f3c729e..c08812e46f 100644 --- a/test/components/structures/LoggedInView-test.tsx +++ b/test/components/structures/LoggedInView-test.tsx @@ -24,6 +24,7 @@ import SettingsStore from "../../../src/settings/SettingsStore"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Action } from "../../../src/dispatcher/actions"; import Modal from "../../../src/Modal"; +import { SETTINGS } from "../../../src/settings/Settings"; describe("", () => { const userId = "@alice:domain.org"; @@ -37,6 +38,9 @@ describe("", () => { setPushRuleEnabled: jest.fn(), setPushRuleActions: jest.fn(), getCrypto: jest.fn().mockReturnValue(undefined), + setExtendedProfileProperty: jest.fn().mockResolvedValue(undefined), + deleteExtendedProfileProperty: jest.fn().mockResolvedValue(undefined), + doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(true), }); const mediaHandler = new MediaHandler(mockClient); const mockSdkContext = new TestSdkContext(); @@ -409,4 +413,48 @@ describe("", () => { await userEvent.keyboard("{Control>}{Alt>}h{/Control}"); expect(defaultDispatcher.dispatch).not.toHaveBeenCalledWith({ action: Action.ViewHomePage }); }); + + describe("timezone updates", () => { + const userTimezone = "Europe/London"; + const originalController = SETTINGS["userTimezonePublish"].controller; + + beforeEach(async () => { + SETTINGS["userTimezonePublish"].controller = undefined; + await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, false); + await SettingsStore.setValue("userTimezone", null, SettingLevel.DEVICE, userTimezone); + }); + + afterEach(() => { + SETTINGS["userTimezonePublish"].controller = originalController; + }); + + it("does not update the timezone when userTimezonePublish is off", async () => { + getComponent(); + await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, false); + expect(mockClient.deleteExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz"); + expect(mockClient.setExtendedProfileProperty).not.toHaveBeenCalled(); + }); + it("should set the user timezone when userTimezonePublish is enabled", async () => { + getComponent(); + await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, true); + expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", userTimezone); + }); + + it("should set the user timezone when the timezone is changed", async () => { + const newTimezone = "Europe/Paris"; + getComponent(); + await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, true); + expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", userTimezone); + await SettingsStore.setValue("userTimezone", null, SettingLevel.DEVICE, newTimezone); + expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", newTimezone); + }); + + it("should clear the timezone when the publish feature is turned off", async () => { + getComponent(); + await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, true); + expect(mockClient.setExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz", userTimezone); + await SettingsStore.setValue("userTimezonePublish", null, SettingLevel.DEVICE, false); + expect(mockClient.deleteExtendedProfileProperty).toHaveBeenCalledWith("us.cloke.msc4175.tz"); + }); + }); }); diff --git a/test/components/views/auth/InteractiveAuthEntryComponents-test.tsx b/test/components/views/auth/InteractiveAuthEntryComponents-test.tsx index 62c02b0d58..1cbf799af7 100644 --- a/test/components/views/auth/InteractiveAuthEntryComponents-test.tsx +++ b/test/components/views/auth/InteractiveAuthEntryComponents-test.tsx @@ -7,11 +7,14 @@ */ 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 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"; describe("", () => { @@ -55,3 +58,44 @@ describe("", () => { await waitFor(() => expect(screen.queryByRole("button", { name: "Resend" })).toBeInTheDocument()); }); }); + +describe("", () => { + const renderAuth = (props = {}) => { + const matrixClient = createTestClient(); + + return render( + , + ); + }; + + 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({}); + }); +}); diff --git a/test/components/views/auth/__snapshots__/InteractiveAuthEntryComponents-test.tsx.snap b/test/components/views/auth/__snapshots__/InteractiveAuthEntryComponents-test.tsx.snap index 65f86a35d2..16e5b3abc2 100644 --- a/test/components/views/auth/__snapshots__/InteractiveAuthEntryComponents-test.tsx.snap +++ b/test/components/views/auth/__snapshots__/InteractiveAuthEntryComponents-test.tsx.snap @@ -32,3 +32,53 @@ exports[` should render 1`] = ` `; + +exports[` should render 1`] = ` +
+
+

+ Reset your identity through your account provider and then come back and click “Retry”. +

+
+ + +
+
+
+`; diff --git a/test/components/views/right_panel/PinnedMessagesCard-test.tsx b/test/components/views/right_panel/PinnedMessagesCard-test.tsx index 9fd212b43c..8f8ffa3520 100644 --- a/test/components/views/right_panel/PinnedMessagesCard-test.tsx +++ b/test/components/views/right_panel/PinnedMessagesCard-test.tsx @@ -196,6 +196,21 @@ describe("", () => { expect(asFragment()).toMatchSnapshot(); }); + it("should not show more than 100 messages", async () => { + const events = Array.from({ length: 120 }, (_, i) => + mkMessage({ + event: true, + room: "!room:example.org", + user: "@alice:example.org", + msg: `The message ${i}`, + ts: i, + }), + ); + await initPinnedMessagesCard(events, []); + + expect(screen.queryAllByRole("listitem")).toHaveLength(100); + }); + it("should updates when messages are pinned", async () => { // Start with nothing pinned const { addLocalPinEvent, addNonLocalPinEvent } = await initPinnedMessagesCard([], []); diff --git a/test/components/views/right_panel/UserInfo-test.tsx b/test/components/views/right_panel/UserInfo-test.tsx index 06944629b6..8c21246fa5 100644 --- a/test/components/views/right_panel/UserInfo-test.tsx +++ b/test/components/views/right_panel/UserInfo-test.tsx @@ -92,6 +92,7 @@ let mockRoom: Mocked; let mockSpace: Mocked; let mockClient: Mocked; let mockCrypto: Mocked; +const origDate = global.Date.prototype.toLocaleString; beforeEach(() => { mockRoom = mocked({ @@ -150,6 +151,8 @@ beforeEach(() => { isSynapseAdministrator: jest.fn().mockResolvedValue(false), isRoomEncrypted: jest.fn().mockReturnValue(false), doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false), + doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false), + getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")), mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"), removeListener: jest.fn(), currentState: { @@ -229,6 +232,28 @@ describe("", () => { expect(screen.getByRole("heading", { name: defaultUserId })).toBeInTheDocument(); }); + it("renders user timezone if set", async () => { + // For timezone, force a consistent locale. + jest.spyOn(global.Date.prototype, "toLocaleString").mockImplementation(function ( + this: Date, + _locale, + opts, + ) { + return origDate.call(this, "en-US", opts); + }); + mockClient.doesServerSupportExtendedProfiles.mockResolvedValue(true); + mockClient.getExtendedProfileProperty.mockResolvedValue("Europe/London"); + renderComponent(); + await expect(screen.findByText(/\d\d:\d\d (AM|PM)/)).resolves.toBeInTheDocument(); + }); + + it("does not renders user timezone if timezone is invalid", async () => { + mockClient.doesServerSupportExtendedProfiles.mockResolvedValue(true); + mockClient.getExtendedProfileProperty.mockResolvedValue("invalid-tz"); + renderComponent(); + expect(screen.queryByText(/\d\d:\d\d (AM|PM)/)).not.toBeInTheDocument(); + }); + it("renders encryption info panel without pending verification", () => { renderComponent({ phase: RightPanelPhases.EncryptionPanel }); expect(screen.getByRole("heading", { name: /encryption/i })).toBeInTheDocument(); diff --git a/test/components/views/right_panel/__snapshots__/PinnedMessagesCard-test.tsx.snap b/test/components/views/right_panel/__snapshots__/PinnedMessagesCard-test.tsx.snap index a055fdcca8..95573aa55e 100644 --- a/test/components/views/right_panel/__snapshots__/PinnedMessagesCard-test.tsx.snap +++ b/test/components/views/right_panel/__snapshots__/PinnedMessagesCard-test.tsx.snap @@ -358,7 +358,7 @@ exports[` unpin all should not allow to unpinall 1`] = ` aria-label="Open menu" class="_icon-button_bh2qc_17" data-state="closed" - id="radix-18" + id="radix-218" role="button" style="--cpd-icon-button-size: 24px;" tabindex="0" @@ -424,7 +424,7 @@ exports[` unpin all should not allow to unpinall 1`] = ` aria-label="Open menu" class="_icon-button_bh2qc_17" data-state="closed" - id="radix-19" + id="radix-219" role="button" style="--cpd-icon-button-size: 24px;" tabindex="0" diff --git a/test/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap b/test/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap index 74b5375ebc..ddbbe2e000 100644 --- a/test/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap +++ b/test/components/views/settings/tabs/user/__snapshots__/PreferencesUserSettingsTab-test.tsx.snap @@ -307,6 +307,33 @@ exports[`PreferencesUserSettingsTab should render 1`] = ` /> +
+ +
+
+
+
-
- -
- Use Ctrl + Enter to send a message + Enable Emoji suggestions while typing
- Surround selected text when typing special characters + Use Ctrl + Enter to send a message
- Show stickers button + Surround selected text when typing special characters
+ + Show stickers button + + +
+
+
+
+
+
-
- -
+ + Expand code blocks by default + + +
+
+
+
+
+
-
- -
- Autoplay videos + Autoplay GIFs
+ + Autoplay videos + + +
+
+
+
+
+
-
- -
- Show read receipts sent by other users + Show a placeholder for removed messages
- Show join/leave messages (invites/removes/bans unaffected) + Show read receipts sent by other users
- Show display name changes + Show join/leave messages (invites/removes/bans unaffected)
- Show chat effects (animations when receiving e.g. confetti) + Show display name changes
- Show profile picture changes + Show chat effects (animations when receiving e.g. confetti)
- Show avatars in user, room and event mentions + Show profile picture changes
- Enable big emoji in chat + Show avatars in user, room and event mentions
- Jump to the bottom of the timeline when you send a message + Enable big emoji in chat
+ + Jump to the bottom of the timeline when you send a message + + +
+
+
+
+
+