Merge matrix-react-sdk into element-web

Merge remote-tracking branch 'repomerge/t3chguy/repomerge' into t3chguy/repo-merge

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski 2024-10-15 14:57:26 +01:00
commit f0ee7f7905
No known key found for this signature in database
GPG key ID: A2B008A5F49F5D0D
3265 changed files with 484599 additions and 699 deletions

View file

@ -0,0 +1,62 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useState } from "react";
import { Room } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import BaseDialog from "./BaseDialog";
import AccessibleButton from "../elements/AccessibleButton";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { AddExistingToSpace, defaultSpacesRenderer, SubspaceSelector } from "./AddExistingToSpaceDialog";
interface IProps {
space: Room;
onCreateSubspaceClick(): void;
onFinished(added?: boolean): void;
}
const AddExistingSubspaceDialog: React.FC<IProps> = ({ space, onCreateSubspaceClick, onFinished }) => {
const [selectedSpace, setSelectedSpace] = useState(space);
return (
<BaseDialog
title={
<SubspaceSelector
title={_t("space|add_existing_subspace|space_dropdown_title")}
space={space}
value={selectedSpace}
onChange={setSelectedSpace}
/>
}
className="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpace"
onFinished={onFinished}
fixedWidth={false}
>
<MatrixClientContext.Provider value={space.client}>
<AddExistingToSpace
space={space}
onFinished={onFinished}
footerPrompt={
<>
<div>{_t("space|add_existing_subspace|create_prompt")}</div>
<AccessibleButton onClick={onCreateSubspaceClick} kind="link">
{_t("space|add_existing_subspace|create_button")}
</AccessibleButton>
</>
}
filterPlaceholder={_t("space|add_existing_subspace|filter_placeholder")}
spacesRenderer={defaultSpacesRenderer}
/>
</MatrixClientContext.Provider>
</BaseDialog>
);
};
export default AddExistingSubspaceDialog;

View file

@ -0,0 +1,512 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ReactElement, ReactNode, useContext, useMemo, useRef, useState } from "react";
import classNames from "classnames";
import { Room, EventType } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { sleep } from "matrix-js-sdk/src/utils";
import { logger } from "matrix-js-sdk/src/logger";
import { _t, _td, TranslationKey } from "../../../languageHandler";
import BaseDialog from "./BaseDialog";
import Dropdown from "../elements/Dropdown";
import SearchBox from "../../structures/SearchBox";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import RoomAvatar from "../avatars/RoomAvatar";
import { getDisplayAliasForRoom } from "../../../Rooms";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import DMRoomMap from "../../../utils/DMRoomMap";
import { calculateRoomVia } from "../../../utils/permalinks/Permalinks";
import StyledCheckbox from "../elements/StyledCheckbox";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import ProgressBar from "../elements/ProgressBar";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
import LazyRenderList from "../elements/LazyRenderList";
import { useSettingValue } from "../../../hooks/useSettings";
import { filterBoolean } from "../../../utils/arrays";
import { NonEmptyArray } from "../../../@types/common";
// These values match CSS
const ROW_HEIGHT = 32 + 12;
const HEADER_HEIGHT = 15;
const GROUP_MARGIN = 24;
interface IProps {
space: Room;
onCreateRoomClick(ev: ButtonEvent): void;
onAddSubspaceClick(): void;
onFinished(added?: boolean): void;
}
export const Entry: React.FC<{
room: Room;
checked: boolean;
onChange?(value: boolean): void;
}> = ({ room, checked, onChange }) => {
return (
<label className="mx_AddExistingToSpace_entry">
{room?.isSpaceRoom() ? (
<RoomAvatar room={room} size="32px" />
) : (
<DecoratedRoomAvatar room={room} size="32px" />
)}
<span className="mx_AddExistingToSpace_entry_name">{room.name}</span>
<StyledCheckbox
onChange={onChange ? (e) => onChange(e.currentTarget.checked) : undefined}
checked={checked}
disabled={!onChange}
/>
</label>
);
};
type OnChangeFn = (checked: boolean, room: Room) => void;
type Renderer = (
rooms: Room[],
selectedToAdd: Set<Room>,
scrollState: IScrollState,
onChange: undefined | OnChangeFn,
) => ReactNode;
interface IAddExistingToSpaceProps {
space: Room;
footerPrompt?: ReactNode;
filterPlaceholder: string;
emptySelectionButton?: ReactNode;
onFinished(added: boolean): void;
roomsRenderer?: Renderer;
spacesRenderer?: Renderer;
dmsRenderer?: Renderer;
}
interface IScrollState {
scrollTop: number;
height: number;
}
const getScrollState = (
{ scrollTop, height }: IScrollState,
numItems: number,
...prevGroupSizes: number[]
): IScrollState => {
let heightBefore = 0;
prevGroupSizes.forEach((size) => {
heightBefore += GROUP_MARGIN + HEADER_HEIGHT + size * ROW_HEIGHT;
});
const viewportTop = scrollTop;
const viewportBottom = viewportTop + height;
const listTop = heightBefore + HEADER_HEIGHT;
const listBottom = listTop + numItems * ROW_HEIGHT;
const top = Math.max(viewportTop, listTop);
const bottom = Math.min(viewportBottom, listBottom);
// the viewport height and scrollTop passed to the LazyRenderList
// is capped at the intersection with the real viewport, so lists
// out of view are passed height 0, so they won't render any items.
return {
scrollTop: Math.max(0, scrollTop - listTop),
height: Math.max(0, bottom - top),
};
};
export const AddExistingToSpace: React.FC<IAddExistingToSpaceProps> = ({
space,
footerPrompt,
emptySelectionButton,
filterPlaceholder,
roomsRenderer,
dmsRenderer,
spacesRenderer,
onFinished,
}) => {
const cli = useContext(MatrixClientContext);
const msc3946ProcessDynamicPredecessor = useSettingValue<boolean>("feature_dynamic_room_predecessors");
const visibleRooms = useMemo(
() =>
cli
?.getVisibleRooms(msc3946ProcessDynamicPredecessor)
.filter((r) => r.getMyMembership() === KnownMembership.Join) ?? [],
[cli, msc3946ProcessDynamicPredecessor],
);
const scrollRef = useRef<AutoHideScrollbar<"div">>(null);
const [scrollState, setScrollState] = useState<IScrollState>({
// these are estimates which update as soon as it mounts
scrollTop: 0,
height: 600,
});
const [selectedToAdd, setSelectedToAdd] = useState(new Set<Room>());
const [progress, setProgress] = useState<number | null>(null);
const [error, setError] = useState(false);
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase().trim();
const existingSubspacesSet = useMemo(() => new Set(SpaceStore.instance.getChildSpaces(space.roomId)), [space]);
const existingRoomsSet = useMemo(() => new Set(SpaceStore.instance.getChildRooms(space.roomId)), [space]);
const [spaces, rooms, dms] = useMemo(() => {
let rooms = visibleRooms;
if (lcQuery) {
const matcher = new QueryMatcher<Room>(visibleRooms, {
keys: ["name"],
funcs: [(r) => filterBoolean([r.getCanonicalAlias(), ...r.getAltAliases()])],
shouldMatchWordsOnly: false,
});
rooms = matcher.match(lcQuery);
}
const joinRule = space.getJoinRule();
return sortRooms(rooms).reduce<[spaces: Room[], rooms: Room[], dms: Room[]]>(
(arr, room) => {
if (room.isSpaceRoom()) {
if (room !== space && !existingSubspacesSet.has(room)) {
arr[0].push(room);
}
} else if (!existingRoomsSet.has(room)) {
if (!DMRoomMap.shared().getUserIdForRoomId(room.roomId)) {
arr[1].push(room);
} else if (joinRule !== "public") {
// Only show DMs for non-public spaces as they make very little sense in spaces other than "Just Me" ones.
arr[2].push(room);
}
}
return arr;
},
[[], [], []],
);
}, [visibleRooms, space, lcQuery, existingRoomsSet, existingSubspacesSet]);
const addRooms = async (): Promise<void> => {
setError(false);
setProgress(0);
let error = false;
for (const room of selectedToAdd) {
const via = calculateRoomVia(room);
try {
await SpaceStore.instance.addRoomToSpace(space, room.roomId, via).catch(async (e): Promise<void> => {
if (e.errcode === "M_LIMIT_EXCEEDED") {
await sleep(e.data.retry_after_ms);
await SpaceStore.instance.addRoomToSpace(space, room.roomId, via); // retry
return;
}
throw e;
});
setProgress((i) => (i ?? 0) + 1);
} catch (e) {
logger.error("Failed to add rooms to space", e);
error = true;
break;
}
}
if (!error) {
onFinished(true);
} else {
setError(error);
}
};
const busy = progress !== null;
let footer;
if (error) {
footer = (
<>
<img
src={require("../../../../res/img/element-icons/warning-badge.svg").default}
height="24"
width="24"
alt=""
/>
<span className="mx_AddExistingToSpaceDialog_error">
<div className="mx_AddExistingToSpaceDialog_errorHeading">
{_t("space|add_existing_room_space|error_heading")}
</div>
<div className="mx_AddExistingToSpaceDialog_errorCaption">{_t("action|try_again")}</div>
</span>
<AccessibleButton className="mx_AddExistingToSpaceDialog_retryButton" onClick={addRooms}>
{_t("action|retry")}
</AccessibleButton>
</>
);
} else if (busy) {
footer = (
<span>
<ProgressBar value={progress} max={selectedToAdd.size} />
<div className="mx_AddExistingToSpaceDialog_progressText">
{_t("space|add_existing_room_space|progress_text", {
count: selectedToAdd.size,
progress,
})}
</div>
</span>
);
} else {
let button = emptySelectionButton;
if (!button || selectedToAdd.size > 0) {
button = (
<AccessibleButton kind="primary" disabled={selectedToAdd.size < 1} onClick={addRooms}>
{_t("action|add")}
</AccessibleButton>
);
}
footer = (
<>
<span>{footerPrompt}</span>
{button}
</>
);
}
const onChange =
!busy && !error
? (checked: boolean, room: Room) => {
if (checked) {
selectedToAdd.add(room);
} else {
selectedToAdd.delete(room);
}
setSelectedToAdd(new Set(selectedToAdd));
}
: undefined;
// only count spaces when alone as they're shown on a separate modal all on their own
const numSpaces = spacesRenderer && !dmsRenderer && !roomsRenderer ? spaces.length : 0;
const numRooms = roomsRenderer ? rooms.length : 0;
const numDms = dmsRenderer ? dms.length : 0;
let noResults = true;
if (numSpaces > 0 || numRooms > 0 || numDms > 0) {
noResults = false;
}
const onScroll = (): void => {
const body = scrollRef.current?.containerRef.current;
if (!body) return;
setScrollState({
scrollTop: body.scrollTop,
height: body.clientHeight,
});
};
const wrappedRef = (body: HTMLDivElement | null): void => {
if (!body) return;
setScrollState({
scrollTop: body.scrollTop,
height: body.clientHeight,
});
};
const roomsScrollState = getScrollState(scrollState, numRooms);
const spacesScrollState = getScrollState(scrollState, numSpaces, numRooms);
const dmsScrollState = getScrollState(scrollState, numDms, numSpaces, numRooms);
return (
<div className="mx_AddExistingToSpace">
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={filterPlaceholder}
onSearch={setQuery}
autoFocus={true}
/>
<AutoHideScrollbar
className="mx_AddExistingToSpace_content"
onScroll={onScroll}
wrappedRef={wrappedRef}
ref={scrollRef}
>
{rooms.length > 0 && roomsRenderer
? roomsRenderer(rooms, selectedToAdd, roomsScrollState, onChange)
: undefined}
{spaces.length > 0 && spacesRenderer
? spacesRenderer(spaces, selectedToAdd, spacesScrollState, onChange)
: null}
{dms.length > 0 && dmsRenderer ? dmsRenderer(dms, selectedToAdd, dmsScrollState, onChange) : null}
{noResults ? (
<span className="mx_AddExistingToSpace_noResults">{_t("common|no_results")}</span>
) : undefined}
</AutoHideScrollbar>
<div className="mx_AddExistingToSpace_footer">{footer}</div>
</div>
);
};
const defaultRendererFactory =
(title: TranslationKey): Renderer =>
(rooms, selectedToAdd, { scrollTop, height }, onChange) => (
<div className="mx_AddExistingToSpace_section">
<h3>{_t(title)}</h3>
<LazyRenderList
itemHeight={ROW_HEIGHT}
items={rooms}
scrollTop={scrollTop}
height={height}
renderItem={(room) => (
<Entry
key={room.roomId}
room={room}
checked={selectedToAdd.has(room)}
onChange={
onChange
? (checked: boolean) => {
onChange(checked, room);
}
: undefined
}
/>
)}
/>
</div>
);
export const defaultRoomsRenderer = defaultRendererFactory(_td("common|rooms"));
export const defaultSpacesRenderer = defaultRendererFactory(_td("common|spaces"));
export const defaultDmsRenderer = defaultRendererFactory(_td("space|add_existing_room_space|dm_heading"));
interface ISubspaceSelectorProps {
title: string;
space: Room;
value: Room;
onChange(space: Room): void;
}
export const SubspaceSelector: React.FC<ISubspaceSelectorProps> = ({ title, space, value, onChange }) => {
const options = useMemo(() => {
return [
space,
...SpaceStore.instance.getChildSpaces(space.roomId).filter((space) => {
return space.currentState.maySendStateEvent(EventType.SpaceChild, space.client.getSafeUserId());
}),
];
}, [space]);
let body;
if (options.length > 1) {
body = (
<Dropdown
id="mx_SpaceSelectDropdown"
className="mx_SpaceSelectDropdown"
onOptionChange={(key: string) => {
onChange(options.find((space) => space.roomId === key) || space);
}}
value={value.roomId}
label={_t("space|add_existing_room_space|space_dropdown_label")}
>
{
options.map((space) => {
const classes = classNames({
mx_SubspaceSelector_dropdownOptionActive: space === value,
});
return (
<div key={space.roomId} className={classes}>
<RoomAvatar room={space} size="24px" />
{space.name || getDisplayAliasForRoom(space) || space.roomId}
</div>
);
}) as NonEmptyArray<ReactElement & { key: string }>
}
</Dropdown>
);
} else {
body = (
<div className="mx_SubspaceSelector_onlySpace">
{space.name || getDisplayAliasForRoom(space) || space.roomId}
</div>
);
}
return (
<div className="mx_SubspaceSelector">
<RoomAvatar room={value} size="40px" />
<div>
<h1>{title}</h1>
{body}
</div>
</div>
);
};
const AddExistingToSpaceDialog: React.FC<IProps> = ({ space, onCreateRoomClick, onAddSubspaceClick, onFinished }) => {
const [selectedSpace, setSelectedSpace] = useState(space);
return (
<BaseDialog
title={
<SubspaceSelector
title={_t("space|add_existing_room_space|space_dropdown_title")}
space={space}
value={selectedSpace}
onChange={setSelectedSpace}
/>
}
className="mx_AddExistingToSpaceDialog"
contentId="mx_AddExistingToSpace"
onFinished={onFinished}
fixedWidth={false}
>
<MatrixClientContext.Provider value={space.client}>
<AddExistingToSpace
space={space}
onFinished={onFinished}
footerPrompt={
<>
<div>{_t("space|add_existing_room_space|create")}</div>
<AccessibleButton
kind="link"
onClick={(ev: ButtonEvent) => {
onCreateRoomClick(ev);
onFinished();
}}
>
{_t("space|add_existing_room_space|create_prompt")}
</AccessibleButton>
</>
}
filterPlaceholder={_t("space|room_filter_placeholder")}
roomsRenderer={defaultRoomsRenderer}
spacesRenderer={() => (
<div className="mx_AddExistingToSpace_section">
<h3>{_t("common|spaces")}</h3>
<AccessibleButton
kind="link"
onClick={() => {
onAddSubspaceClick();
onFinished();
}}
>
{_t("space|add_existing_room_space|subspace_moved_note")}
</AccessibleButton>
</div>
)}
dmsRenderer={defaultDmsRenderer}
/>
</MatrixClientContext.Provider>
</BaseDialog>
);
};
export default AddExistingToSpaceDialog;

View file

@ -0,0 +1,106 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import BaseDialog from "./BaseDialog";
import { _t } from "../../../languageHandler";
import DialogButtons from "../elements/DialogButtons";
import Modal, { ComponentProps } from "../../../Modal";
import SdkConfig from "../../../SdkConfig";
import { getPolicyUrl } from "../../../toasts/AnalyticsToast";
import ExternalLink from "../elements/ExternalLink";
export enum ButtonClicked {
Primary,
Cancel,
}
interface IProps {
onFinished(buttonClicked?: ButtonClicked): void;
analyticsOwner: string;
privacyPolicyUrl?: string;
primaryButton?: string;
cancelButton?: string;
hasCancel?: boolean;
}
export const AnalyticsLearnMoreDialog: React.FC<IProps> = ({
onFinished,
analyticsOwner,
privacyPolicyUrl,
primaryButton,
cancelButton,
hasCancel,
}) => {
const onPrimaryButtonClick = (): void => onFinished(ButtonClicked.Primary);
const onCancelButtonClick = (): void => onFinished(ButtonClicked.Cancel);
const privacyPolicyLink = privacyPolicyUrl ? (
<span>
{_t(
"analytics|privacy_policy",
{},
{
PrivacyPolicyUrl: (sub) => {
return (
<ExternalLink href={privacyPolicyUrl} rel="norefferer noopener" target="_blank">
{sub}
</ExternalLink>
);
},
},
)}
</span>
) : (
""
);
return (
<BaseDialog
className="mx_AnalyticsLearnMoreDialog"
contentId="mx_AnalyticsLearnMore"
title={_t("analytics|enable_prompt", { analyticsOwner })}
onFinished={onFinished}
>
<div className="mx_Dialog_content">
<div className="mx_AnalyticsLearnMore_image_holder" />
<div className="mx_AnalyticsLearnMore_copy">
{_t("analytics|pseudonymous_usage_data", { analyticsOwner })}
</div>
<ul className="mx_AnalyticsLearnMore_bullets">
<li>{_t("analytics|bullet_1", {}, { Bold: (sub) => <strong>{sub}</strong> })}</li>
<li>{_t("analytics|bullet_2", {}, { Bold: (sub) => <strong>{sub}</strong> })}</li>
<li>{_t("analytics|disable_prompt")}</li>
</ul>
{privacyPolicyLink}
</div>
<DialogButtons
primaryButton={primaryButton}
cancelButton={cancelButton}
onPrimaryButtonClick={onPrimaryButtonClick}
onCancel={onCancelButtonClick}
hasCancel={hasCancel}
/>
</BaseDialog>
);
};
export const showDialog = (
props: Omit<ComponentProps<typeof AnalyticsLearnMoreDialog>, "cookiePolicyUrl" | "analyticsOwner">,
): void => {
const privacyPolicyUrl = getPolicyUrl();
const analyticsOwner = SdkConfig.get("analytics_owner") ?? SdkConfig.get("brand");
Modal.createDialog(
AnalyticsLearnMoreDialog,
{
privacyPolicyUrl,
analyticsOwner,
...props,
},
"mx_AnalyticsLearnMoreDialog_wrapper",
);
};

View file

@ -0,0 +1,136 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { FC } from "react";
import { Icon as FDroidBadge } from "../../../../res/img/badges/f-droid.svg";
import { Icon as GooglePlayBadge } from "../../../../res/img/badges/google-play.svg";
import { Icon as IOSBadge } from "../../../../res/img/badges/ios.svg";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import AccessibleButton from "../elements/AccessibleButton";
import QRCode from "../elements/QRCode";
import Heading from "../typography/Heading";
import BaseDialog from "./BaseDialog";
interface Props {
onFinished(): void;
}
export const showAppDownloadDialogPrompt = (): boolean => {
const desktopBuilds = SdkConfig.getObject("desktop_builds");
const mobileBuilds = SdkConfig.getObject("mobile_builds");
return (
!!desktopBuilds?.get("available") ||
!!mobileBuilds?.get("ios") ||
!!mobileBuilds?.get("android") ||
!!mobileBuilds?.get("fdroid")
);
};
export const AppDownloadDialog: FC<Props> = ({ onFinished }) => {
const brand = SdkConfig.get("brand");
const desktopBuilds = SdkConfig.getObject("desktop_builds");
const mobileBuilds = SdkConfig.getObject("mobile_builds");
const urlAppStore = mobileBuilds?.get("ios");
const urlGooglePlay = mobileBuilds?.get("android");
const urlFDroid = mobileBuilds?.get("fdroid");
const urlAndroid = urlGooglePlay ?? urlFDroid;
return (
<BaseDialog
title={_t("onboarding|download_brand", { brand })}
className="mx_AppDownloadDialog"
fixedWidth
onFinished={onFinished}
>
{desktopBuilds?.get("available") && (
<div className="mx_AppDownloadDialog_desktop">
<Heading size="3">{_t("onboarding|download_brand_desktop", { brand })}</Heading>
<AccessibleButton
kind="primary"
element="a"
href={desktopBuilds?.get("url")}
target="_blank"
onClick={() => {}}
>
{_t("onboarding|download_brand_desktop", { brand })}
</AccessibleButton>
</div>
)}
<div className="mx_AppDownloadDialog_mobile">
{urlAppStore && (
<div className="mx_AppDownloadDialog_app">
<Heading size="3">{_t("common|ios")}</Heading>
<QRCode data={urlAppStore} margin={0} width={172} />
<div className="mx_AppDownloadDialog_info">
{_t("onboarding|qr_or_app_links", {
appLinks: "",
qrCode: "",
})}
</div>
<div className="mx_AppDownloadDialog_links">
<AccessibleButton
element="a"
href={urlAppStore}
target="_blank"
aria-label={_t("onboarding|download_app_store")}
onClick={() => {}}
>
<IOSBadge />
</AccessibleButton>
</div>
</div>
)}
{urlAndroid && (
<div className="mx_AppDownloadDialog_app">
<Heading size="3">{_t("common|android")}</Heading>
<QRCode data={urlAndroid} margin={0} width={172} />
<div className="mx_AppDownloadDialog_info">
{_t("onboarding|qr_or_app_links", {
appLinks: "",
qrCode: "",
})}
</div>
<div className="mx_AppDownloadDialog_links">
{urlGooglePlay && (
<AccessibleButton
element="a"
href={urlGooglePlay}
target="_blank"
aria-label={_t("onboarding|download_google_play")}
onClick={() => {}}
>
<GooglePlayBadge />
</AccessibleButton>
)}
{urlFDroid && (
<AccessibleButton
element="a"
href={urlFDroid}
target="_blank"
aria-label={_t("onboarding|download_f_droid")}
onClick={() => {}}
>
<FDroidBadge />
</AccessibleButton>
)}
</div>
</div>
)}
</div>
<div className="mx_AppDownloadDialog_legal">
<p>{_t("onboarding|apple_trademarks")}</p>
<p>{_t("onboarding|google_trademarks")}</p>
</div>
</BaseDialog>
);
};

View file

@ -0,0 +1,90 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2019 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.
*/
import React, { useCallback } from "react";
import { _t } from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import { SettingLevel } from "../../../settings/SettingLevel";
import BaseDialog from "./BaseDialog";
export interface UnknownProfile {
userId: string;
errorText: string;
}
export type UnknownProfiles = UnknownProfile[];
export interface AskInviteAnywayDialogProps {
unknownProfileUsers: UnknownProfiles;
onInviteAnyways: () => void;
onGiveUp: () => void;
onFinished: (success: boolean) => void;
description?: string;
inviteNeverWarnLabel?: string;
inviteLabel?: string;
}
export default function AskInviteAnywayDialog({
onFinished,
onGiveUp,
onInviteAnyways,
unknownProfileUsers,
description: descriptionProp,
inviteNeverWarnLabel,
inviteLabel,
}: AskInviteAnywayDialogProps): JSX.Element {
const onInviteClicked = useCallback((): void => {
onInviteAnyways();
onFinished(true);
}, [onInviteAnyways, onFinished]);
const onInviteNeverWarnClicked = useCallback((): void => {
SettingsStore.setValue("promptBeforeInviteUnknownUsers", null, SettingLevel.ACCOUNT, false);
onInviteAnyways();
onFinished(true);
}, [onInviteAnyways, onFinished]);
const onGiveUpClicked = useCallback((): void => {
onGiveUp();
onFinished(false);
}, [onGiveUp, onFinished]);
const errorList = unknownProfileUsers.map((address) => (
<li key={address.userId}>
{address.userId}: {address.errorText}
</li>
));
const description = descriptionProp ?? _t("invite|unable_find_profiles_description_default");
return (
<BaseDialog
className="mx_RetryInvitesDialog"
onFinished={onGiveUpClicked}
title={_t("invite|unable_find_profiles_title")}
contentId="mx_Dialog_content"
>
<div id="mx_Dialog_content">
<p>{description}</p>
<ul>{errorList}</ul>
</div>
<div className="mx_Dialog_buttons">
<button onClick={onGiveUpClicked}>{_t("action|close")}</button>
<button onClick={onInviteNeverWarnClicked}>
{inviteNeverWarnLabel ?? _t("invite|unable_find_profiles_invite_never_warn_label_default")}
</button>
<button onClick={onInviteClicked} autoFocus={true}>
{inviteLabel ?? _t("invite|unable_find_profiles_invite_label_default")}
</button>
</div>
</BaseDialog>
);
}

View file

@ -0,0 +1,187 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2018, 2019 New Vector Ltd
Copyright 2017 Vector Creations Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import FocusLock from "react-focus-lock";
import classNames from "classnames";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import AccessibleButton from "../elements/AccessibleButton";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { _t } from "../../../languageHandler";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import Heading from "../typography/Heading";
import { PosthogScreenTracker, ScreenName } from "../../../PosthogTrackers";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
interface IProps {
// Whether the dialog should have a 'close' button that will
// cause the dialog to be cancelled. This should only be set
// to false if there is nothing the app can sensibly do if the
// dialog is cancelled, eg. "We can't restore your session and
// the app cannot work". Default: true.
"hasCancel"?: boolean;
// called when a key is pressed
"onKeyDown"?: (e: KeyboardEvent | React.KeyboardEvent) => void;
// CSS class to apply to dialog div
"className"?: string;
// if true, dialog container is 60% of the viewport width. Otherwise,
// the container will have no fixed size, allowing its contents to
// determine its size. Default: true.
"fixedWidth"?: boolean;
// To be displayed at the top of the dialog. Even above the title.
"top"?: React.ReactNode;
// Title for the dialog.
"title"?: React.ReactNode;
// Specific aria label to use, if not provided will set aria-labelledBy to mx_Dialog_title
"aria-label"?: string;
// Path to an icon to put in the header
"headerImage"?: string;
// children should be the content of the dialog
"children"?: React.ReactNode;
// Id of content element
// If provided, this is used to add a aria-describedby attribute
"contentId"?: string;
// optional additional class for the title element (basically anything that can be passed to classnames)
"titleClass"?: string | string[];
"headerButton"?: JSX.Element;
// optional Posthog ScreenName to supply during the lifetime of this dialog
"screenName"?: ScreenName;
onFinished(): void;
}
/*
* Basic container for modal dialogs.
*
* Includes a div for the title, and a keypress handler which cancels the
* dialog on escape.
*/
export default class BaseDialog extends React.Component<IProps> {
private matrixClient: MatrixClient;
public static defaultProps: Partial<IProps> = {
hasCancel: true,
fixedWidth: true,
};
public constructor(props: IProps) {
super(props);
// XXX: The contract on MatrixClientContext says it is only available within a LoggedInView subtree,
// given that modals function outside the MatrixChat React tree this simulates that. We don't want to
// use safeGet as it throwing would mean we cannot use modals whilst the user isn't logged in.
// The longer term solution is to move our ModalManager into the React tree to inherit contexts properly.
this.matrixClient = MatrixClientPeg.get()!;
}
private onKeyDown = (e: KeyboardEvent | React.KeyboardEvent): void => {
this.props.onKeyDown?.(e);
const action = getKeyBindingsManager().getAccessibilityAction(e);
switch (action) {
case KeyBindingAction.Escape:
if (!this.props.hasCancel) break;
e.stopPropagation();
e.preventDefault();
this.props.onFinished();
break;
}
};
private onCancelClick = (): void => {
this.props.onFinished();
};
public render(): React.ReactNode {
let cancelButton;
if (this.props.hasCancel) {
cancelButton = (
<AccessibleButton
onClick={this.onCancelClick}
className="mx_Dialog_cancelButton"
aria-label={_t("dialog_close_label")}
title={_t("action|close")}
placement="bottom"
/>
);
}
let headerImage;
if (this.props.headerImage) {
headerImage = <img className="mx_Dialog_titleImage" src={this.props.headerImage} alt="" />;
}
const lockProps: Record<string, any> = {
"onKeyDown": this.onKeyDown,
"role": "dialog",
// This should point to a node describing the dialog.
// If we were about to completely follow this recommendation we'd need to
// make all the components relying on BaseDialog to be aware of it.
// So instead we will use the whole content as the description.
// Description comes first and if the content contains more text,
// AT users can skip its presentation.
"aria-describedby": this.props.contentId,
};
if (this.props["aria-label"]) {
lockProps["aria-label"] = this.props["aria-label"];
} else {
lockProps["aria-labelledby"] = "mx_BaseDialog_title";
}
return (
<MatrixClientContext.Provider value={this.matrixClient}>
{this.props.screenName && <PosthogScreenTracker screenName={this.props.screenName} />}
<FocusLock
returnFocus={true}
lockProps={lockProps}
className={classNames(this.props.className, {
mx_Dialog_fixedWidth: this.props.fixedWidth,
})}
>
{this.props.top}
<div
className={classNames("mx_Dialog_header", {
mx_Dialog_headerWithButton: !!this.props.headerButton,
})}
>
{!!(this.props.title || headerImage) && (
<Heading
size="3"
as="h1"
className={classNames("mx_Dialog_title", this.props.titleClass)}
id="mx_BaseDialog_title"
>
{headerImage}
{this.props.title}
</Heading>
)}
{this.props.headerButton}
</div>
{this.props.children}
{cancelButton}
</FocusLock>
</MatrixClientContext.Provider>
);
}
}

View file

@ -0,0 +1,58 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { _t } from "../../../languageHandler";
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from "../elements/AccessibleButton";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { UserTab } from "./UserTab";
import GenericFeatureFeedbackDialog from "./GenericFeatureFeedbackDialog";
// XXX: Keep this around for re-use in future Betas
interface IProps {
featureId: string;
onFinished(sendFeedback?: boolean): void;
}
const BetaFeedbackDialog: React.FC<IProps> = ({ featureId, onFinished }) => {
const info = SettingsStore.getBetaInfo(featureId);
if (!info) return null;
return (
<GenericFeatureFeedbackDialog
title={_t("labs|beta_feedback_title", { featureName: info.title })}
subheading={info.feedbackSubheading ? _t(info.feedbackSubheading) : undefined}
onFinished={onFinished}
rageshakeLabel={info.feedbackLabel}
rageshakeData={Object.fromEntries(
(SettingsStore.getBetaInfo(featureId)?.extraSettings || []).map((k) => {
return SettingsStore.getValue(k);
}),
)}
>
<AccessibleButton
kind="link_inline"
onClick={() => {
onFinished();
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Labs,
});
}}
>
{_t("labs|beta_feedback_leave_button")}
</AccessibleButton>
</GenericFeatureFeedbackDialog>
);
};
export default BetaFeedbackDialog;

View file

@ -0,0 +1,281 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Copyright 2017 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import SdkConfig from "../../../SdkConfig";
import Modal from "../../../Modal";
import { _t } from "../../../languageHandler";
import sendBugReport, { downloadBugReport } from "../../../rageshake/submit-rageshake";
import AccessibleButton from "../elements/AccessibleButton";
import QuestionDialog from "./QuestionDialog";
import BaseDialog from "./BaseDialog";
import Field from "../elements/Field";
import Spinner from "../elements/Spinner";
import DialogButtons from "../elements/DialogButtons";
import { sendSentryReport } from "../../../sentry";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { getBrowserSupport } from "../../../SupportedBrowser";
interface IProps {
onFinished: (success: boolean) => void;
initialText?: string;
label?: string;
error?: unknown;
}
interface IState {
sendLogs: boolean;
busy: boolean;
err: string | null;
issueUrl: string;
text: string;
progress: string | null;
downloadBusy: boolean;
downloadProgress: string | null;
}
export default class BugReportDialog extends React.Component<IProps, IState> {
private unmounted: boolean;
private issueRef: React.RefObject<Field>;
public constructor(props: IProps) {
super(props);
this.state = {
sendLogs: true,
busy: false,
err: null,
issueUrl: "",
text: props.initialText || "",
progress: null,
downloadBusy: false,
downloadProgress: null,
};
this.unmounted = false;
this.issueRef = React.createRef();
// Get all of the extra info dumped to the console when someone is about
// to send debug logs. Since this is a fire and forget action, we do
// this when the bug report dialog is opened instead of when we submit
// logs because we have no signal to know when all of the various
// components have finished logging. Someone could potentially send logs
// before we fully dump everything but it's probably unlikely.
defaultDispatcher.dispatch({
action: Action.DumpDebugLogs,
});
}
public componentDidMount(): void {
this.issueRef.current?.focus();
}
public componentWillUnmount(): void {
this.unmounted = true;
}
private onCancel = (): void => {
this.props.onFinished(false);
};
private onSubmit = (): void => {
if ((!this.state.text || !this.state.text.trim()) && (!this.state.issueUrl || !this.state.issueUrl.trim())) {
this.setState({
err: _t("bug_reporting|error_empty"),
});
return;
}
const userText =
(this.state.text.length > 0 ? this.state.text + "\n\n" : "") +
"Issue: " +
(this.state.issueUrl.length > 0 ? this.state.issueUrl : "No issue link given");
this.setState({ busy: true, progress: null, err: null });
this.sendProgressCallback(_t("bug_reporting|preparing_logs"));
sendBugReport(SdkConfig.get().bug_report_endpoint_url, {
userText,
sendLogs: true,
progressCallback: this.sendProgressCallback,
labels: this.props.label ? [this.props.label] : [],
}).then(
() => {
if (!this.unmounted) {
this.props.onFinished(false);
Modal.createDialog(QuestionDialog, {
title: _t("bug_reporting|logs_sent"),
description: _t("bug_reporting|thank_you"),
hasCancelButton: false,
});
}
},
(err) => {
if (!this.unmounted) {
this.setState({
busy: false,
progress: null,
err: _t("bug_reporting|failed_send_logs") + `${err.message}`,
});
}
},
);
sendSentryReport(this.state.text, this.state.issueUrl, this.props.error);
};
private onDownload = async (): Promise<void> => {
this.setState({ downloadBusy: true });
this.downloadProgressCallback(_t("bug_reporting|preparing_download"));
try {
await downloadBugReport({
sendLogs: true,
progressCallback: this.downloadProgressCallback,
labels: this.props.label ? [this.props.label] : [],
});
this.setState({
downloadBusy: false,
downloadProgress: null,
});
} catch (err) {
if (!this.unmounted) {
this.setState({
downloadBusy: false,
downloadProgress:
_t("bug_reporting|failed_send_logs") + `${err instanceof Error ? err.message : ""}`,
});
}
}
};
private onTextChange = (ev: React.FormEvent<HTMLTextAreaElement>): void => {
this.setState({ text: ev.currentTarget.value });
};
private onIssueUrlChange = (ev: React.FormEvent<HTMLInputElement>): void => {
this.setState({ issueUrl: ev.currentTarget.value });
};
private sendProgressCallback = (progress: string): void => {
if (this.unmounted) {
return;
}
this.setState({ progress });
};
private downloadProgressCallback = (downloadProgress: string): void => {
if (this.unmounted) {
return;
}
this.setState({ downloadProgress });
};
public render(): React.ReactNode {
let error: JSX.Element | undefined;
if (this.state.err) {
error = <div className="error">{this.state.err}</div>;
}
let progress: JSX.Element | undefined;
if (this.state.busy) {
progress = (
<div className="progress">
<Spinner />
{this.state.progress} ...
</div>
);
}
let warning: JSX.Element | undefined;
if (
(window.Modernizr && Object.values(window.Modernizr).some((support) => support === false)) ||
!getBrowserSupport()
) {
warning = (
<p>
<strong>{_t("bug_reporting|unsupported_browser")}</strong>
</p>
);
}
return (
<BaseDialog
className="mx_BugReportDialog"
onFinished={this.onCancel}
title={_t("bug_reporting|submit_debug_logs")}
contentId="mx_Dialog_content"
>
<div className="mx_Dialog_content" id="mx_Dialog_content">
{warning}
<p>{_t("bug_reporting|description")}</p>
<p>
<strong>
{_t(
"bug_reporting|before_submitting",
{},
{
a: (sub) => (
<a
target="_blank"
href={SdkConfig.get().feedback.new_issue_url}
rel="noreferrer noopener"
>
{sub}
</a>
),
},
)}
</strong>
</p>
<div className="mx_BugReportDialog_download">
<AccessibleButton onClick={this.onDownload} kind="link" disabled={this.state.downloadBusy}>
{_t("bug_reporting|download_logs")}
</AccessibleButton>
{this.state.downloadProgress && <span>{this.state.downloadProgress} ...</span>}
</div>
<Field
type="text"
className="mx_BugReportDialog_field_input"
label={_t("bug_reporting|github_issue")}
onChange={this.onIssueUrlChange}
value={this.state.issueUrl}
placeholder="https://github.com/vector-im/element-web/issues/..."
ref={this.issueRef}
/>
<Field
className="mx_BugReportDialog_field_input"
element="textarea"
label={_t("bug_reporting|textarea_label")}
rows={5}
onChange={this.onTextChange}
value={this.state.text}
placeholder={_t("bug_reporting|additional_context")}
/>
{progress}
{error}
</div>
<DialogButtons
primaryButton={_t("bug_reporting|send_logs")}
onPrimaryButtonClick={this.onSubmit}
focus={true}
onCancel={this.onCancel}
disabled={this.state.busy}
/>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,131 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useState } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClient, RoomMember, Room, MatrixEvent, EventTimeline, EventType } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import BaseDialog from "../dialogs/BaseDialog";
import InfoDialog from "../dialogs/InfoDialog";
import DialogButtons from "../elements/DialogButtons";
import StyledCheckbox from "../elements/StyledCheckbox";
interface Props {
matrixClient: MatrixClient;
room: Room;
member: RoomMember;
onFinished(redact?: boolean): void;
}
const BulkRedactDialog: React.FC<Props> = (props) => {
const { matrixClient: cli, room, member, onFinished } = props;
const [keepStateEvents, setKeepStateEvents] = useState(true);
let timeline: EventTimeline | null = room.getLiveTimeline();
let eventsToRedact: MatrixEvent[] = [];
while (timeline) {
eventsToRedact = [
...eventsToRedact,
...timeline.getEvents().filter(
(event) =>
event.getSender() === member.userId &&
!event.isRedacted() &&
!event.isRedaction() &&
event.getType() !== EventType.RoomCreate &&
// Don't redact ACLs because that'll obliterate the room
// See https://github.com/matrix-org/synapse/issues/4042 for details.
event.getType() !== EventType.RoomServerAcl &&
// Redacting encryption events is equally bad
event.getType() !== EventType.RoomEncryption,
),
];
timeline = timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS);
}
if (eventsToRedact.length === 0) {
return (
<InfoDialog
onFinished={onFinished}
title={_t("user_info|redact|no_recent_messages_title", { user: member.name })}
description={
<div>
<p>{_t("user_info|redact|no_recent_messages_description")}</p>
</div>
}
/>
);
} else {
eventsToRedact = eventsToRedact.filter((event) => !(keepStateEvents && event.isState()));
const count = eventsToRedact.length;
const user = member.name;
const redact = async (): Promise<void> => {
logger.info(`Started redacting recent ${count} messages for ${member.userId} in ${room.roomId}`);
dis.dispatch({
action: Action.BulkRedactStart,
room_id: room.roomId,
});
// Submitting a large number of redactions freezes the UI,
// so first yield to allow to rerender after closing the dialog.
await Promise.resolve();
await Promise.all(
eventsToRedact.reverse().map(async (event): Promise<void> => {
try {
await cli.redactEvent(room.roomId, event.getId()!);
} catch (err) {
// log and swallow errors
logger.error("Could not redact", event.getId());
logger.error(err);
}
}),
);
logger.info(`Finished redacting recent ${count} messages for ${member.userId} in ${room.roomId}`);
dis.dispatch({
action: Action.BulkRedactEnd,
room_id: room.roomId,
});
};
return (
<BaseDialog
className="mx_BulkRedactDialog"
onFinished={onFinished}
title={_t("user_info|redact|confirm_title", { user })}
contentId="mx_Dialog_content"
>
<div className="mx_Dialog_content" id="mx_Dialog_content">
<p>{_t("user_info|redact|confirm_description_1", { count, user })}</p>
<p>{_t("user_info|redact|confirm_description_2")}</p>
<StyledCheckbox checked={keepStateEvents} onChange={(e) => setKeepStateEvents(e.target.checked)}>
{_t("user_info|redact|confirm_keep_state_label")}
</StyledCheckbox>
<div className="mx_BulkRedactDialog_checkboxMicrocopy">
{_t("user_info|redact|confirm_keep_state_explainer")}
</div>
</div>
<DialogButtons
primaryButton={_t("user_info|redact|confirm_button", { count })}
primaryButtonClass="danger"
primaryDisabled={count === 0}
onPrimaryButtonClick={() => {
setTimeout(redact, 0);
onFinished(true);
}}
onCancel={() => onFinished(false)}
/>
</BaseDialog>
);
}
};
export default BulkRedactDialog;

View file

@ -0,0 +1,21 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { _t } from "../../../languageHandler";
import Modal from "../../../Modal";
import InfoDialog from "./InfoDialog";
export const createCantStartVoiceMessageBroadcastDialog = (): void => {
Modal.createDialog(InfoDialog, {
title: _t("voice_message|cant_start_broadcast_title"),
description: <p>{_t("voice_message|cant_start_broadcast_description")}</p>,
hasCloseButton: true,
});
};

View file

@ -0,0 +1,123 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2016 Aviral Dasgupta
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { _t } from "../../../languageHandler";
import QuestionDialog from "./QuestionDialog";
import Spinner from "../elements/Spinner";
import Heading from "../typography/Heading";
interface IProps {
newVersion: string;
version: string;
onFinished: (success: boolean) => void;
}
type State = Partial<Record<(typeof REPOS)[number], null | string | Commit[]>>;
interface Commit {
sha: string;
html_url: string;
commit: {
message: string;
};
}
const REPOS = ["element-hq/element-web", "matrix-org/matrix-js-sdk"] as const;
export default class ChangelogDialog extends React.Component<IProps, State> {
public constructor(props: IProps) {
super(props);
this.state = {};
}
private async fetchChanges(repo: (typeof REPOS)[number], oldVersion: string, newVersion: string): Promise<void> {
const url = `https://riot.im/github/repos/${repo}/compare/${oldVersion}...${newVersion}`;
try {
const res = await fetch(url);
if (!res.ok) {
this.setState({ [repo]: res.statusText });
return;
}
const body = await res.json();
this.setState({ [repo]: body.commits });
} catch (err) {
this.setState({ [repo]: err instanceof Error ? err.message : _t("error|unknown") });
}
}
public componentDidMount(): void {
const version = this.props.newVersion.split("-");
const version2 = this.props.version.split("-");
if (version == null || version2 == null) return;
// parse versions of form: [vectorversion]-react-[react-sdk-version]-js-[js-sdk-version]
for (let i = 0; i < REPOS.length; i++) {
const oldVersion = version2[2 * i];
const newVersion = version[2 * i];
this.fetchChanges(REPOS[i], oldVersion, newVersion);
}
}
private elementsForCommit(commit: Commit): JSX.Element {
return (
<li key={commit.sha} className="mx_ChangelogDialog_li">
<a href={commit.html_url} target="_blank" rel="noreferrer noopener">
{commit.commit.message.split("\n")[0]}
</a>
</li>
);
}
public render(): React.ReactNode {
const logs = REPOS.map((repo) => {
let content;
if (this.state[repo] == null) {
content = <Spinner key={repo} />;
} else if (typeof this.state[repo] === "string") {
content = _t("update|error_unable_load_commit", {
msg: this.state[repo],
});
} else {
content = (this.state[repo] as Commit[]).map(this.elementsForCommit);
}
return (
<div key={repo}>
<Heading as="h2" size="4">
{repo}
</Heading>
<ul>{content}</ul>
</div>
);
});
const content = (
<div className="mx_ChangelogDialog_content">
{this.props.version == null || this.props.newVersion == null ? (
<h2>{_t("update|unavailable")}</h2>
) : (
logs
)}
</div>
);
return (
<QuestionDialog
title={_t("update|changelog")}
description={content}
button={_t("action|update")}
onFinished={this.props.onFinished}
/>
);
}
}

View file

@ -0,0 +1,97 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { MatrixEvent, HTTPError, MatrixError } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import ConfirmRedactDialog from "./ConfirmRedactDialog";
import ErrorDialog from "./ErrorDialog";
import BaseDialog from "./BaseDialog";
import Spinner from "../elements/Spinner";
interface IProps {
event: MatrixEvent;
redact: () => Promise<void>;
onFinished: (success?: boolean) => void;
}
interface IState {
isRedacting: boolean;
redactionErrorCode: string | number | null;
}
/*
* A dialog for confirming a redaction.
* Also shows a spinner (and possible error) while the redaction is ongoing,
* and only closes the dialog when the redaction is done or failed.
*
* This is done to prevent the edit history dialog racing with the redaction:
* if this dialog closes and the MessageEditHistoryDialog is shown again,
* it will fetch the relations again, which will race with the ongoing /redact request.
* which will cause the edit to appear unredacted.
*
* To avoid this, we keep the dialog open as long as /redact is in progress.
*/
export default class ConfirmAndWaitRedactDialog extends React.PureComponent<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
isRedacting: false,
redactionErrorCode: null,
};
}
public onParentFinished = async (proceed?: boolean): Promise<void> => {
if (proceed) {
this.setState({ isRedacting: true });
try {
await this.props.redact();
this.props.onFinished(true);
} catch (error) {
let code: string | number | undefined;
if (error instanceof MatrixError) {
code = error.errcode;
} else if (error instanceof HTTPError) {
code = error.httpStatus;
}
if (typeof code !== "undefined") {
this.setState({ redactionErrorCode: code });
} else {
this.props.onFinished(true);
}
}
} else {
this.props.onFinished(false);
}
};
public render(): React.ReactNode {
if (this.state.isRedacting) {
if (this.state.redactionErrorCode) {
const code = this.state.redactionErrorCode;
return (
<ErrorDialog
onFinished={this.props.onFinished}
title={_t("common|error")}
description={_t("redact|error", { code })}
/>
);
} else {
return (
<BaseDialog onFinished={this.props.onFinished} hasCancel={false} title={_t("redact|ongoing")}>
<Spinner />
</BaseDialog>
);
}
} else {
return <ConfirmRedactDialog event={this.props.event} onFinished={this.onParentFinished} />;
}
}
}

View file

@ -0,0 +1,108 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017 Vector Creations Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
import { IRedactOpts, MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix";
import React from "react";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Modal from "../../../Modal";
import { isVoiceBroadcastStartedEvent } from "../../../voice-broadcast/utils/isVoiceBroadcastStartedEvent";
import ErrorDialog from "./ErrorDialog";
import TextInputDialog from "./TextInputDialog";
interface IProps {
event: MatrixEvent;
onFinished(success?: false, reason?: void): void;
onFinished(success: true, reason?: string): void;
}
/*
* A dialog for confirming a redaction.
*/
export default class ConfirmRedactDialog extends React.Component<IProps> {
public render(): React.ReactNode {
let description = _t("redact|confirm_description");
if (this.props.event.isState()) {
description += " " + _t("redact|confirm_description_state");
}
return (
<TextInputDialog
onFinished={this.props.onFinished}
title={_t("redact|confirm_button")}
description={description}
placeholder={_t("redact|reason_label")}
focus
button={_t("action|remove")}
/>
);
}
}
export function createRedactEventDialog({
mxEvent,
onCloseDialog = () => {},
}: {
mxEvent: MatrixEvent;
onCloseDialog?: () => void;
}): void {
const eventId = mxEvent.getId();
if (!eventId) throw new Error("cannot redact event without ID");
const roomId = mxEvent.getRoomId();
if (!roomId) throw new Error(`cannot redact event ${mxEvent.getId()} without room ID`);
Modal.createDialog(
ConfirmRedactDialog,
{
event: mxEvent,
onFinished: async (proceed, reason): Promise<void> => {
if (!proceed) return;
const cli = MatrixClientPeg.safeGet();
const withRelTypes: Pick<IRedactOpts, "with_rel_types"> = {};
// redact related events if this is a voice broadcast started event and
// server has support for relation based redactions
if (isVoiceBroadcastStartedEvent(mxEvent)) {
const relationBasedRedactionsSupport = cli.canSupport.get(Feature.RelationBasedRedactions);
if (
relationBasedRedactionsSupport &&
relationBasedRedactionsSupport !== ServerSupport.Unsupported
) {
withRelTypes.with_rel_types = [RelationType.Reference];
}
}
try {
onCloseDialog?.();
await cli.redactEvent(roomId, eventId, undefined, {
...(reason ? { reason } : {}),
...withRelTypes,
});
} catch (e: any) {
const code = e.errcode || e.statusCode;
// only show the dialog if failing for something other than a network error
// (e.g. no errcode or statusCode) as in that case the redactions end up in the
// detached queue and we show the room status bar to allow retry
if (typeof code !== "undefined") {
// display error message stating you couldn't delete this.
Modal.createDialog(ErrorDialog, {
title: _t("common|error"),
description: _t("redact|error", { code }),
});
}
}
},
},
"mx_Dialog_confirmredact",
);
}

View file

@ -0,0 +1,76 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ComponentProps, useMemo, useState } from "react";
import { Room } from "matrix-js-sdk/src/matrix";
import ConfirmUserActionDialog from "./ConfirmUserActionDialog";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import SpaceChildrenPicker from "../spaces/SpaceChildrenPicker";
type BaseProps = ComponentProps<typeof ConfirmUserActionDialog>;
interface IProps extends Omit<BaseProps, "matrixClient" | "children" | "onFinished"> {
space: Room;
allLabel: string;
specificLabel: string;
noneLabel?: string;
warningMessage?: string;
onFinished(success?: boolean, reason?: string, rooms?: Room[]): void;
spaceChildFilter?(child: Room): boolean;
}
const ConfirmSpaceUserActionDialog: React.FC<IProps> = ({
space,
spaceChildFilter,
allLabel,
specificLabel,
noneLabel,
warningMessage,
onFinished,
...props
}) => {
const spaceChildren = useMemo(() => {
const children = SpaceStore.instance.getChildren(space.roomId);
if (spaceChildFilter) {
return children.filter(spaceChildFilter);
}
return children;
}, [space.roomId, spaceChildFilter]);
const [roomsToLeave, setRoomsToLeave] = useState<Room[]>([]);
const selectedRooms = useMemo(() => new Set(roomsToLeave), [roomsToLeave]);
let warning: JSX.Element | undefined;
if (warningMessage) {
warning = <div className="mx_ConfirmSpaceUserActionDialog_warning">{warningMessage}</div>;
}
return (
<ConfirmUserActionDialog
{...props}
onFinished={(success?: boolean, reason?: string) => {
onFinished(success, reason, roomsToLeave);
}}
className="mx_ConfirmSpaceUserActionDialog"
roomId={space.roomId}
>
{warning}
<SpaceChildrenPicker
space={space}
spaceChildren={spaceChildren}
selected={selectedRooms}
allLabel={allLabel}
specificLabel={specificLabel}
noneLabel={noneLabel}
onChange={setRoomsToLeave}
/>
</ConfirmUserActionDialog>
);
};
export default ConfirmSpaceUserActionDialog;

View file

@ -0,0 +1,133 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2017 Vector Creations Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ChangeEvent, FormEvent, ReactNode } from "react";
import { RoomMember } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import MemberAvatar from "../avatars/MemberAvatar";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import Field from "../elements/Field";
import UserIdentifierCustomisations from "../../../customisations/UserIdentifier";
interface IProps {
// matrix-js-sdk (room) member object.
member: RoomMember;
action: string; // eg. 'Ban'
title: string; // eg. 'Ban this user?'
// Whether to display a text field for a reason
// If true, the second argument to onFinished will
// be the string entered.
askReason?: boolean;
danger?: boolean;
children?: ReactNode;
className?: string;
roomId?: string;
onFinished: (success?: boolean, reason?: string) => void;
}
interface IState {
reason: string;
}
/*
* A dialog for confirming an operation on another user.
* Takes a user ID and a verb, displays the target user prominently
* such that it should be easy to confirm that the operation is being
* performed on the right person, and displays the operation prominently
* to make it obvious what is going to happen.
* Also tweaks the style for 'dangerous' actions (albeit only with colour)
*/
export default class ConfirmUserActionDialog extends React.Component<IProps, IState> {
public static defaultProps: Partial<IProps> = {
danger: false,
askReason: false,
};
public constructor(props: IProps) {
super(props);
this.state = {
reason: "",
};
}
private onOk = (ev: FormEvent): void => {
ev.preventDefault();
this.props.onFinished(true, this.state.reason);
};
private onCancel = (): void => {
this.props.onFinished(false);
};
private onReasonChange = (ev: ChangeEvent<HTMLInputElement>): void => {
this.setState({
reason: ev.target.value,
});
};
public render(): React.ReactNode {
const confirmButtonClass = this.props.danger ? "danger" : "";
let reasonBox;
if (this.props.askReason) {
reasonBox = (
<form onSubmit={this.onOk}>
<Field
type="text"
onChange={this.onReasonChange}
value={this.state.reason}
className="mx_ConfirmUserActionDialog_reasonField"
label={_t("room_settings|permissions|ban_reason")}
autoFocus={true}
/>
</form>
);
}
const avatar = <MemberAvatar member={this.props.member} size="48px" />;
const name = this.props.member.name;
const userId = this.props.member.userId;
const displayUserIdentifier = UserIdentifierCustomisations.getDisplayUserIdentifier(userId, {
roomId: this.props.roomId,
withDisplayName: true,
});
return (
<BaseDialog
className={classNames("mx_ConfirmUserActionDialog", this.props.className)}
onFinished={this.props.onFinished}
title={this.props.title}
contentId="mx_Dialog_content"
>
<div id="mx_Dialog_content" className="mx_Dialog_content">
<div className="mx_ConfirmUserActionDialog_user">
<div className="mx_ConfirmUserActionDialog_avatar">{avatar}</div>
<div className="mx_ConfirmUserActionDialog_name">{name}</div>
<div className="mx_ConfirmUserActionDialog_userId">{displayUserIdentifier}</div>
</div>
{reasonBox}
{this.props.children}
</div>
<DialogButtons
primaryButton={this.props.action}
onPrimaryButtonClick={this.onOk}
primaryButtonClass={confirmButtonClass}
focus={!this.props.askReason}
onCancel={this.onCancel}
/>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,49 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { _t } from "../../../languageHandler";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
interface IProps {
onFinished: (success?: boolean) => void;
}
export default class ConfirmWipeDeviceDialog extends React.Component<IProps> {
private onConfirm = (): void => {
this.props.onFinished(true);
};
private onDecline = (): void => {
this.props.onFinished(false);
};
public render(): React.ReactNode {
return (
<BaseDialog
className="mx_ConfirmWipeDeviceDialog"
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("auth|soft_logout|clear_data_title")}
>
<div className="mx_ConfirmWipeDeviceDialog_content">
<p>{_t("auth|soft_logout|clear_data_description")}</p>
</div>
<DialogButtons
primaryButton={_t("auth|soft_logout|clear_data_button")}
onPrimaryButtonClick={this.onConfirm}
primaryButtonClass="danger"
cancelButton={_t("action|cancel")}
onCancel={this.onDecline}
/>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,454 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
Copyright 2017 Michael Telatynski <7t3chguy@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ChangeEvent, createRef, KeyboardEvent, SyntheticEvent } from "react";
import { Room, RoomType, JoinRule, Preset, Visibility } from "matrix-js-sdk/src/matrix";
import SdkConfig from "../../../SdkConfig";
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { checkUserIsAllowedToChangeEncryption, IOpts } from "../../../createRoom";
import Field from "../elements/Field";
import RoomAliasField from "../elements/RoomAliasField";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog";
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { privateShouldBeEncrypted } from "../../../utils/rooms";
import SettingsStore from "../../../settings/SettingsStore";
import LabelledCheckbox from "../elements/LabelledCheckbox";
interface IProps {
type?: RoomType;
defaultPublic?: boolean;
defaultName?: string;
parentSpace?: Room;
defaultEncrypted?: boolean;
onFinished(proceed?: false): void;
onFinished(proceed: true, opts: IOpts): void;
}
interface IState {
/**
* The selected room join rule.
*/
joinRule: JoinRule;
/**
* Indicates whether the created room should have public visibility (ie, it should be
* shown in the public room list). Only applicable if `joinRule` == `JoinRule.Knock`.
*/
isPublicKnockRoom: boolean;
/**
* Indicates whether end-to-end encryption is enabled for the room.
*/
isEncrypted: boolean;
/**
* The room name.
*/
name: string;
/**
* The room topic.
*/
topic: string;
/**
* The room alias.
*/
alias: string;
/**
* Indicates whether the details section is open.
*/
detailsOpen: boolean;
/**
* Indicates whether federation is disabled for the room.
*/
noFederate: boolean;
/**
* Indicates whether the room name is valid.
*/
nameIsValid: boolean;
/**
* Indicates whether the user can change encryption settings for the room.
*/
canChangeEncryption: boolean;
}
export default class CreateRoomDialog extends React.Component<IProps, IState> {
private readonly askToJoinEnabled: boolean;
private readonly supportsRestricted: boolean;
private nameField = createRef<Field>();
private aliasField = createRef<RoomAliasField>();
public constructor(props: IProps) {
super(props);
this.askToJoinEnabled = SettingsStore.getValue("feature_ask_to_join");
this.supportsRestricted = !!this.props.parentSpace;
let joinRule = JoinRule.Invite;
if (this.props.defaultPublic) {
joinRule = JoinRule.Public;
} else if (this.supportsRestricted) {
joinRule = JoinRule.Restricted;
}
const cli = MatrixClientPeg.safeGet();
this.state = {
isPublicKnockRoom: this.props.defaultPublic || false,
isEncrypted: this.props.defaultEncrypted ?? privateShouldBeEncrypted(cli),
joinRule,
name: this.props.defaultName || "",
topic: "",
alias: "",
detailsOpen: false,
noFederate: SdkConfig.get().default_federate === false,
nameIsValid: false,
canChangeEncryption: false,
};
checkUserIsAllowedToChangeEncryption(cli, Preset.PrivateChat).then(({ allowChange, forcedValue }) =>
this.setState((state) => ({
canChangeEncryption: allowChange,
// override with forcedValue if it is set
isEncrypted: forcedValue ?? state.isEncrypted,
})),
);
}
private roomCreateOptions(): IOpts {
const opts: IOpts = {};
const createOpts: IOpts["createOpts"] = (opts.createOpts = {});
opts.roomType = this.props.type;
createOpts.name = this.state.name;
if (this.state.joinRule === JoinRule.Public) {
createOpts.visibility = Visibility.Public;
createOpts.preset = Preset.PublicChat;
opts.guestAccess = false;
const { alias } = this.state;
createOpts.room_alias_name = alias.substring(1, alias.indexOf(":"));
} else {
opts.encryption = this.state.isEncrypted;
}
if (this.state.topic) {
createOpts.topic = this.state.topic;
}
if (this.state.noFederate) {
createOpts.creation_content = { "m.federate": false };
}
opts.parentSpace = this.props.parentSpace;
if (this.props.parentSpace && this.state.joinRule === JoinRule.Restricted) {
opts.joinRule = JoinRule.Restricted;
}
if (this.state.joinRule === JoinRule.Knock) {
opts.joinRule = JoinRule.Knock;
createOpts.visibility = this.state.isPublicKnockRoom ? Visibility.Public : Visibility.Private;
}
return opts;
}
public componentDidMount(): void {
// move focus to first field when showing dialog
this.nameField.current?.focus();
}
private onKeyDown = (event: KeyboardEvent): void => {
const action = getKeyBindingsManager().getAccessibilityAction(event);
switch (action) {
case KeyBindingAction.Enter:
this.onOk();
event.preventDefault();
event.stopPropagation();
break;
}
};
private onOk = async (): Promise<void> => {
if (!this.nameField.current) return;
const activeElement = document.activeElement as HTMLElement;
activeElement?.blur();
await this.nameField.current.validate({ allowEmpty: false });
if (this.aliasField.current) {
await this.aliasField.current.validate({ allowEmpty: false });
}
// Validation and state updates are async, so we need to wait for them to complete
// first. Queue a `setState` callback and wait for it to resolve.
await new Promise<void>((resolve) => this.setState({}, resolve));
if (this.state.nameIsValid && (!this.aliasField.current || this.aliasField.current.isValid)) {
this.props.onFinished(true, this.roomCreateOptions());
} else {
let field: RoomAliasField | Field | null = null;
if (!this.state.nameIsValid) {
field = this.nameField.current;
} else if (this.aliasField.current && !this.aliasField.current.isValid) {
field = this.aliasField.current;
}
if (field) {
field.focus();
await field.validate({ allowEmpty: false, focused: true });
}
}
};
private onCancel = (): void => {
this.props.onFinished(false);
};
private onNameChange = (ev: ChangeEvent<HTMLInputElement>): void => {
this.setState({ name: ev.target.value });
};
private onTopicChange = (ev: ChangeEvent<HTMLInputElement>): void => {
this.setState({ topic: ev.target.value });
};
private onJoinRuleChange = (joinRule: JoinRule): void => {
this.setState({ joinRule });
};
private onEncryptedChange = (isEncrypted: boolean): void => {
this.setState({ isEncrypted });
};
private onAliasChange = (alias: string): void => {
this.setState({ alias });
};
private onDetailsToggled = (ev: SyntheticEvent<HTMLDetailsElement>): void => {
this.setState({ detailsOpen: (ev.target as HTMLDetailsElement).open });
};
private onNoFederateChange = (noFederate: boolean): void => {
this.setState({ noFederate });
};
private onNameValidate = async (fieldState: IFieldState): Promise<IValidationResult> => {
const result = await CreateRoomDialog.validateRoomName(fieldState);
this.setState({ nameIsValid: !!result.valid });
return result;
};
private onIsPublicKnockRoomChange = (isPublicKnockRoom: boolean): void => {
this.setState({ isPublicKnockRoom });
};
private static validateRoomName = withValidation({
rules: [
{
key: "required",
test: async ({ value }) => !!value,
invalid: () => _t("create_room|name_validation_required"),
},
],
});
public render(): React.ReactNode {
const isVideoRoom = this.props.type === RoomType.ElementVideo;
let aliasField: JSX.Element | undefined;
if (this.state.joinRule === JoinRule.Public) {
const domain = MatrixClientPeg.safeGet().getDomain()!;
aliasField = (
<div className="mx_CreateRoomDialog_aliasContainer">
<RoomAliasField
ref={this.aliasField}
onChange={this.onAliasChange}
domain={domain}
value={this.state.alias}
/>
</div>
);
}
let publicPrivateLabel: JSX.Element | undefined;
if (this.state.joinRule === JoinRule.Restricted) {
publicPrivateLabel = (
<p>
{_t(
"create_room|join_rule_restricted_label",
{},
{
SpaceName: () => (
<strong>{this.props.parentSpace?.name ?? _t("common|unnamed_space")}</strong>
),
},
)}
&nbsp;
{_t("create_room|join_rule_change_notice")}
</p>
);
} else if (this.state.joinRule === JoinRule.Public && this.props.parentSpace) {
publicPrivateLabel = (
<p>
{_t(
"create_room|join_rule_public_parent_space_label",
{},
{
SpaceName: () => (
<strong>{this.props.parentSpace?.name ?? _t("common|unnamed_space")}</strong>
),
},
)}
&nbsp;
{_t("create_room|join_rule_change_notice")}
</p>
);
} else if (this.state.joinRule === JoinRule.Public) {
publicPrivateLabel = (
<p>
{_t("create_room|join_rule_public_label")}
&nbsp;
{_t("create_room|join_rule_change_notice")}
</p>
);
} else if (this.state.joinRule === JoinRule.Invite) {
publicPrivateLabel = (
<p>
{_t("create_room|join_rule_invite_label")}
&nbsp;
{_t("create_room|join_rule_change_notice")}
</p>
);
} else if (this.state.joinRule === JoinRule.Knock) {
publicPrivateLabel = <p>{_t("create_room|join_rule_knock_label")}</p>;
}
let visibilitySection: JSX.Element | undefined;
if (this.state.joinRule === JoinRule.Knock) {
visibilitySection = (
<LabelledCheckbox
className="mx_CreateRoomDialog_labelledCheckbox"
label={_t("room_settings|security|publish_room")}
onChange={this.onIsPublicKnockRoomChange}
value={this.state.isPublicKnockRoom}
/>
);
}
let e2eeSection: JSX.Element | undefined;
if (this.state.joinRule !== JoinRule.Public) {
let microcopy: string;
if (privateShouldBeEncrypted(MatrixClientPeg.safeGet())) {
if (this.state.canChangeEncryption) {
microcopy = isVideoRoom
? _t("create_room|encrypted_video_room_warning")
: _t("create_room|encrypted_warning");
} else {
microcopy = _t("create_room|encryption_forced");
}
} else {
microcopy = _t("settings|security|e2ee_default_disabled_warning");
}
e2eeSection = (
<React.Fragment>
<LabelledToggleSwitch
label={_t("create_room|encryption_label")}
onChange={this.onEncryptedChange}
value={this.state.isEncrypted}
className="mx_CreateRoomDialog_e2eSwitch" // for end-to-end tests
disabled={!this.state.canChangeEncryption}
/>
<p>{microcopy}</p>
</React.Fragment>
);
}
let federateLabel = _t("create_room|unfederated_label_default_off");
if (SdkConfig.get().default_federate === false) {
// We only change the label if the default setting is different to avoid jarring text changes to the
// user. They will have read the implications of turning this off/on, so no need to rephrase for them.
federateLabel = _t("create_room|unfederated_label_default_on");
}
let title: string;
if (isVideoRoom) {
title = _t("create_room|title_video_room");
} else if (this.props.parentSpace || this.state.joinRule === JoinRule.Knock) {
title = _t("action|create_a_room");
} else {
title =
this.state.joinRule === JoinRule.Public
? _t("create_room|title_public_room")
: _t("create_room|title_private_room");
}
return (
<BaseDialog
className="mx_CreateRoomDialog"
onFinished={this.props.onFinished}
title={title}
screenName="CreateRoom"
>
<form onSubmit={this.onOk} onKeyDown={this.onKeyDown}>
<div className="mx_Dialog_content">
<Field
ref={this.nameField}
label={_t("common|name")}
onChange={this.onNameChange}
onValidate={this.onNameValidate}
value={this.state.name}
className="mx_CreateRoomDialog_name"
/>
<Field
label={_t("create_room|topic_label")}
onChange={this.onTopicChange}
value={this.state.topic}
className="mx_CreateRoomDialog_topic"
/>
<JoinRuleDropdown
label={_t("create_room|room_visibility_label")}
labelInvite={_t("create_room|join_rule_invite")}
labelKnock={
this.askToJoinEnabled ? _t("room_settings|security|join_rule_knock") : undefined
}
labelPublic={_t("common|public_room")}
labelRestricted={
this.supportsRestricted ? _t("create_room|join_rule_restricted") : undefined
}
value={this.state.joinRule}
onChange={this.onJoinRuleChange}
/>
{publicPrivateLabel}
{visibilitySection}
{e2eeSection}
{aliasField}
<details onToggle={this.onDetailsToggled} className="mx_CreateRoomDialog_details">
<summary className="mx_CreateRoomDialog_details_summary">
{this.state.detailsOpen ? _t("action|hide_advanced") : _t("action|show_advanced")}
</summary>
<LabelledToggleSwitch
label={_t("create_room|unfederated", {
serverName: MatrixClientPeg.safeGet().getDomain(),
})}
onChange={this.onNoFederateChange}
value={this.state.noFederate}
/>
<p>{federateLabel}</p>
</details>
</div>
</form>
<DialogButtons
primaryButton={
isVideoRoom ? _t("create_room|action_create_video_room") : _t("create_room|action_create_room")
}
onPrimaryButtonClick={this.onOk}
onCancel={this.onCancel}
/>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,194 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useRef, useState } from "react";
import { Room, JoinRule } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../languageHandler";
import BaseDialog from "./BaseDialog";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { BetaPill } from "../beta/BetaCard";
import Field from "../elements/Field";
import RoomAliasField from "../elements/RoomAliasField";
import { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu";
import { SubspaceSelector } from "./AddExistingToSpaceDialog";
import JoinRuleDropdown from "../elements/JoinRuleDropdown";
interface IProps {
space: Room;
onAddExistingSpaceClick(): void;
onFinished(added?: boolean): void;
}
const CreateSubspaceDialog: React.FC<IProps> = ({ space, onAddExistingSpaceClick, onFinished }) => {
const [parentSpace, setParentSpace] = useState(space);
const [busy, setBusy] = useState<boolean>(false);
const [name, setName] = useState("");
const spaceNameField = useRef<Field>(null);
const [alias, setAlias] = useState("");
const spaceAliasField = useRef<RoomAliasField>(null);
const [avatar, setAvatar] = useState<File | undefined>();
const [topic, setTopic] = useState<string>("");
const spaceJoinRule = space.getJoinRule();
let defaultJoinRule = JoinRule.Restricted;
if (spaceJoinRule === JoinRule.Public) {
defaultJoinRule = JoinRule.Public;
}
const [joinRule, setJoinRule] = useState<JoinRule>(defaultJoinRule);
const onCreateSubspaceClick = async (e: ButtonEvent): Promise<void> => {
e.preventDefault();
if (busy) return;
setBusy(true);
// require & validate the space name field
if (spaceNameField.current && !(await spaceNameField.current.validate({ allowEmpty: false }))) {
spaceNameField.current.focus();
spaceNameField.current.validate({ allowEmpty: false, focused: true });
setBusy(false);
return;
}
// validate the space name alias field but do not require it
if (
spaceAliasField.current &&
joinRule === JoinRule.Public &&
!(await spaceAliasField.current.validate({ allowEmpty: true }))
) {
spaceAliasField.current.focus();
spaceAliasField.current.validate({ allowEmpty: true, focused: true });
setBusy(false);
return;
}
try {
await createSpace(
space.client,
name,
joinRule === JoinRule.Public,
alias,
topic,
avatar,
{},
{ parentSpace, joinRule },
);
onFinished(true);
} catch (e) {
logger.error(e);
}
};
let joinRuleMicrocopy: JSX.Element | undefined;
if (joinRule === JoinRule.Restricted) {
joinRuleMicrocopy = (
<p>
{_t(
"create_space|subspace_join_rule_restricted_description",
{},
{
SpaceName: () => <strong>{parentSpace.name}</strong>,
},
)}
</p>
);
} else if (joinRule === JoinRule.Public) {
joinRuleMicrocopy = (
<p>
{_t(
"create_space|subspace_join_rule_public_description",
{},
{
SpaceName: () => <strong>{parentSpace.name}</strong>,
},
)}
</p>
);
} else if (joinRule === JoinRule.Invite) {
joinRuleMicrocopy = <p>{_t("create_space|subspace_join_rule_invite_description")}</p>;
}
return (
<BaseDialog
title={
<SubspaceSelector
title={_t("create_space|subspace_dropdown_title")}
space={space}
value={parentSpace}
onChange={setParentSpace}
/>
}
className="mx_CreateSubspaceDialog"
contentId="mx_CreateSubspaceDialog"
onFinished={onFinished}
fixedWidth={false}
>
<MatrixClientContext.Provider value={space.client}>
<div className="mx_CreateSubspaceDialog_content">
<div className="mx_CreateSubspaceDialog_betaNotice">
<BetaPill />
{_t("create_space|subspace_beta_notice")}
</div>
<SpaceCreateForm
busy={busy}
onSubmit={onCreateSubspaceClick}
setAvatar={setAvatar}
name={name}
setName={setName}
nameFieldRef={spaceNameField}
topic={topic}
setTopic={setTopic}
alias={alias}
setAlias={setAlias}
showAliasField={joinRule === JoinRule.Public}
aliasFieldRef={spaceAliasField}
>
<JoinRuleDropdown
label={_t("create_space|subspace_join_rule_label")}
labelInvite={_t("create_space|subspace_join_rule_invite_only")}
labelPublic={_t("common|public_space")}
labelRestricted={_t("create_room|join_rule_restricted")}
width={478}
value={joinRule}
onChange={setJoinRule}
/>
{joinRuleMicrocopy}
</SpaceCreateForm>
</div>
<div className="mx_CreateSubspaceDialog_footer">
<div className="mx_CreateSubspaceDialog_footer_prompt">
<div>{_t("create_space|subspace_existing_space_prompt")}</div>
<AccessibleButton
kind="link"
onClick={() => {
onAddExistingSpaceClick();
onFinished();
}}
>
{_t("space|add_existing_subspace|space_dropdown_title")}
</AccessibleButton>
</div>
<AccessibleButton kind="primary_outline" disabled={busy} onClick={() => onFinished(false)}>
{_t("action|cancel")}
</AccessibleButton>
<AccessibleButton kind="primary" disabled={busy} onClick={onCreateSubspaceClick}>
{busy ? _t("create_space|subspace_adding") : _t("action|add")}
</AccessibleButton>
</div>
</MatrixClientContext.Provider>
</BaseDialog>
);
};
export default CreateSubspaceDialog;

View file

@ -0,0 +1,231 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { AuthType, IAuthData } from "matrix-js-sdk/src/interactive-auth";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { _t } from "../../../languageHandler";
import InteractiveAuth, { ERROR_USER_CANCELLED, InteractiveAuthCallback } from "../../structures/InteractiveAuth";
import { ContinueKind, SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents";
import StyledCheckbox from "../elements/StyledCheckbox";
import BaseDialog from "./BaseDialog";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
type DialogAesthetics = Partial<{
[x in AuthType]: {
[x: number]: {
body: string;
continueText?: string;
continueKind?: ContinueKind;
};
};
}>;
interface IProps {
onFinished: (success?: boolean) => void;
}
interface IState {
shouldErase: boolean;
errStr: string | null;
authData: any; // for UIA
authEnabled: boolean; // see usages for information
// A few strings that are passed to InteractiveAuth for design or are displayed
// next to the InteractiveAuth component.
bodyText?: string;
continueText?: string;
continueKind?: ContinueKind;
}
export default class DeactivateAccountDialog extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
shouldErase: false,
errStr: null,
authData: null, // for UIA
authEnabled: true, // see usages for information
};
this.initAuth(/* shouldErase= */ false);
}
private onStagePhaseChange = (stage: AuthType, phase: number): void => {
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
body: _t("settings|general|deactivate_confirm_body_sso"),
continueText: _t("auth|sso"),
continueKind: "danger",
},
[SSOAuthEntry.PHASE_POSTAUTH]: {
body: _t("settings|general|deactivate_confirm_body"),
continueText: _t("settings|general|deactivate_confirm_continue"),
continueKind: "danger",
},
};
// This is the same as aestheticsForStagePhases in InteractiveAuthDialog minus the `title`
const DEACTIVATE_AESTHETICS: DialogAesthetics = {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
};
const aesthetics = DEACTIVATE_AESTHETICS[stage];
let bodyText: string | undefined;
let continueText: string | undefined;
let continueKind: ContinueKind | undefined;
if (aesthetics) {
const phaseAesthetics = aesthetics[phase];
if (phaseAesthetics) {
if (phaseAesthetics.body) bodyText = phaseAesthetics.body;
if (phaseAesthetics.continueText) continueText = phaseAesthetics.continueText;
if (phaseAesthetics.continueKind) continueKind = phaseAesthetics.continueKind;
}
}
this.setState({ bodyText, continueText, continueKind });
};
private onUIAuthFinished: InteractiveAuthCallback<Awaited<ReturnType<MatrixClient["deactivateAccount"]>>> = async (
success,
result,
) => {
if (success) return; // great! makeRequest() will be called too.
if (result === ERROR_USER_CANCELLED) {
this.onCancel();
return;
}
logger.error("Error during UI Auth:", { result });
this.setState({ errStr: _t("settings|general|error_deactivate_communication") });
};
private onUIAuthComplete = (auth: IAuthData | null): void => {
// XXX: this should be returning a promise to maintain the state inside the state machine correct
// but given that a deactivation is followed by a local logout and all object instances being thrown away
// this isn't done.
MatrixClientPeg.safeGet()
.deactivateAccount(auth ?? undefined, this.state.shouldErase)
.then((r) => {
// Deactivation worked - logout & close this dialog
defaultDispatcher.fire(Action.TriggerLogout);
this.props.onFinished(true);
})
.catch((e) => {
logger.error(e);
this.setState({ errStr: _t("settings|general|error_deactivate_communication") });
});
};
private onEraseFieldChange = (ev: React.FormEvent<HTMLInputElement>): void => {
this.setState({
shouldErase: ev.currentTarget.checked,
// Disable the auth form because we're going to have to reinitialize the auth
// information. We do this because we can't modify the parameters in the UIA
// session, and the user will have selected something which changes the request.
// Therefore, we throw away the last auth session and try a new one.
authEnabled: false,
});
// As mentioned above, set up for auth again to get updated UIA session info
this.initAuth(/* shouldErase= */ ev.currentTarget.checked);
};
private onCancel(): void {
this.props.onFinished(false);
}
private initAuth(shouldErase: boolean): void {
MatrixClientPeg.safeGet()
.deactivateAccount(undefined, shouldErase)
.then((r) => {
// If we got here, oops. The server didn't require any auth.
// Our application lifecycle will catch the error and do the logout bits.
// We'll try to log something in an vain attempt to record what happened (storage
// is also obliterated on logout).
logger.warn("User's account got deactivated without confirmation: Server had no auth");
this.setState({ errStr: _t("settings|general|error_deactivate_no_auth") });
})
.catch((e) => {
if (e && e.httpStatus === 401 && e.data) {
// Valid UIA response
this.setState({ authData: e.data, authEnabled: true });
} else {
this.setState({ errStr: _t("settings|general|error_deactivate_invalid_auth") });
}
});
}
public render(): React.ReactNode {
let error: JSX.Element | undefined;
if (this.state.errStr) {
error = <div className="error">{this.state.errStr}</div>;
}
let auth = <div>{_t("common|loading")}</div>;
if (this.state.authData && this.state.authEnabled) {
auth = (
<div>
{this.state.bodyText}
<InteractiveAuth
matrixClient={MatrixClientPeg.safeGet()}
authData={this.state.authData}
// XXX: onUIAuthComplete breaches the expected method contract, it gets away with it because it
// knows the entire app is about to die as a result of the account deactivation.
makeRequest={this.onUIAuthComplete as any}
onAuthFinished={this.onUIAuthFinished}
onStagePhaseChange={this.onStagePhaseChange}
continueText={this.state.continueText}
continueKind={this.state.continueKind}
/>
</div>
);
}
// this is on purpose not a <form /> to prevent Enter triggering submission, to further prevent accidents
return (
<BaseDialog
className="mx_DeactivateAccountDialog"
onFinished={this.props.onFinished}
titleClass="danger"
title={_t("settings|general|deactivate_section")}
screenName="DeactivateAccount"
>
<div className="mx_Dialog_content">
<p>{_t("settings|general|deactivate_confirm_content")}</p>
<ul>
<li>{_t("settings|general|deactivate_confirm_content_1")}</li>
<li>{_t("settings|general|deactivate_confirm_content_2")}</li>
<li>{_t("settings|general|deactivate_confirm_content_3")}</li>
<li>{_t("settings|general|deactivate_confirm_content_4")}</li>
<li>{_t("settings|general|deactivate_confirm_content_5")}</li>
</ul>
<p>{_t("settings|general|deactivate_confirm_content_6")}</p>
<div className="mx_DeactivateAccountDialog_input_section">
<p>
<StyledCheckbox checked={this.state.shouldErase} onChange={this.onEraseFieldChange}>
{_t("settings|general|deactivate_confirm_erase_label")}
</StyledCheckbox>
</p>
{error}
{auth}
</div>
</div>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,143 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2018-2023 The Matrix.org Foundation C.I.C.
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useState } from "react";
import { _t, _td, TranslationKey } from "../../../languageHandler";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import BaseDialog from "./BaseDialog";
import { TimelineEventEditor } from "./devtools/Event";
import ServersInRoom from "./devtools/ServersInRoom";
import VerificationExplorer from "./devtools/VerificationExplorer";
import SettingExplorer from "./devtools/SettingExplorer";
import { RoomStateExplorer } from "./devtools/RoomState";
import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./devtools/BaseTool";
import WidgetExplorer from "./devtools/WidgetExplorer";
import { AccountDataExplorer, RoomAccountDataExplorer } from "./devtools/AccountData";
import SettingsFlag from "../elements/SettingsFlag";
import { SettingLevel } from "../../../settings/SettingLevel";
import ServerInfo from "./devtools/ServerInfo";
import { Features } from "../../../settings/Settings";
import CopyableText from "../elements/CopyableText";
import RoomNotifications from "./devtools/RoomNotifications";
enum Category {
Room,
Other,
}
const categoryLabels: Record<Category, TranslationKey> = {
[Category.Room]: _td("devtools|category_room"),
[Category.Other]: _td("devtools|category_other"),
};
export type Tool = React.FC<IDevtoolsProps> | ((props: IDevtoolsProps) => JSX.Element);
const Tools: Record<Category, [label: TranslationKey, tool: Tool][]> = {
[Category.Room]: [
[_td("devtools|send_custom_timeline_event"), TimelineEventEditor],
[_td("devtools|explore_room_state"), RoomStateExplorer],
[_td("devtools|explore_room_account_data"), RoomAccountDataExplorer],
[_td("devtools|view_servers_in_room"), ServersInRoom],
[_td("devtools|notifications_debug"), RoomNotifications],
[_td("devtools|verification_explorer"), VerificationExplorer],
[_td("devtools|active_widgets"), WidgetExplorer],
],
[Category.Other]: [
[_td("devtools|explore_account_data"), AccountDataExplorer],
[_td("devtools|settings_explorer"), SettingExplorer],
[_td("devtools|server_info"), ServerInfo],
],
};
interface IProps {
roomId: string;
threadRootId?: string | null;
onFinished(finished?: boolean): void;
}
type ToolInfo = [label: TranslationKey, tool: Tool];
const DevtoolsDialog: React.FC<IProps> = ({ roomId, threadRootId, onFinished }) => {
const [tool, setTool] = useState<ToolInfo | null>(null);
let body: JSX.Element;
let onBack: () => void;
if (tool) {
onBack = () => {
setTool(null);
};
const Tool = tool[1];
body = <Tool onBack={onBack} setTool={(label, tool) => setTool([label, tool])} />;
} else {
const onBack = (): void => {
onFinished(false);
};
body = (
<BaseTool onBack={onBack}>
{Object.entries(Tools).map(([category, tools]) => (
<div key={category}>
<h3>{_t(categoryLabels[category as unknown as Category])}</h3>
{tools.map(([label, tool]) => {
const onClick = (): void => {
setTool([label, tool]);
};
return (
<button className="mx_DevTools_button" key={label} onClick={onClick}>
{_t(label)}
</button>
);
})}
</div>
))}
<div>
<h3>{_t("common|options")}</h3>
<SettingsFlag name="developerMode" level={SettingLevel.ACCOUNT} />
<SettingsFlag name="showHiddenEventsInTimeline" level={SettingLevel.DEVICE} />
<SettingsFlag name="enableWidgetScreenshots" level={SettingLevel.ACCOUNT} />
<SettingsFlag name={Features.VoiceBroadcastForceSmallChunks} level={SettingLevel.DEVICE} />
</div>
</BaseTool>
);
}
const label = tool ? _t(tool[0]) : _t("devtools|toolbox");
return (
<BaseDialog className="mx_QuestionDialog" onFinished={onFinished} title={_t("devtools|developer_tools")}>
<MatrixClientContext.Consumer>
{(cli) => (
<>
<div className="mx_DevTools_label_left">{label}</div>
<CopyableText className="mx_DevTools_label_right" getTextToCopy={() => roomId} border={false}>
{_t("devtools|room_id", { roomId })}
</CopyableText>
{!threadRootId ? null : (
<CopyableText
className="mx_DevTools_label_right"
getTextToCopy={() => threadRootId}
border={false}
>
{_t("devtools|thread_root_id", { threadRootId })}
</CopyableText>
)}
<div className="mx_DevTools_label_bottom" />
{cli.getRoom(roomId) && (
<DevtoolsContext.Provider value={{ room: cli.getRoom(roomId)!, threadRootId }}>
{body}
</DevtoolsContext.Provider>
)}
</>
)}
</MatrixClientContext.Consumer>
</BaseDialog>
);
};
export default DevtoolsDialog;

View file

@ -0,0 +1,72 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { MatrixEvent, MatrixClient, TimelineEvents } from "matrix-js-sdk/src/matrix";
import { PollEndEvent } from "matrix-js-sdk/src/extensible_events_v1/PollEndEvent";
import { _t } from "../../../languageHandler";
import QuestionDialog from "./QuestionDialog";
import { findTopAnswer } from "../messages/MPollBody";
import Modal from "../../../Modal";
import ErrorDialog from "./ErrorDialog";
import { GetRelationsForEvent } from "../rooms/EventTile";
interface IProps {
matrixClient: MatrixClient;
event: MatrixEvent;
onFinished: (success?: boolean) => void;
getRelationsForEvent?: GetRelationsForEvent;
}
export default class EndPollDialog extends React.Component<IProps> {
private onFinished = async (endPoll: boolean): Promise<void> => {
if (endPoll) {
const room = this.props.matrixClient.getRoom(this.props.event.getRoomId());
const poll = room?.polls.get(this.props.event.getId()!);
if (!poll) {
throw new Error("No poll instance found in room.");
}
try {
const responses = await poll.getResponses();
const topAnswer = findTopAnswer(this.props.event, responses);
const message =
topAnswer === "" ? _t("poll|end_message_no_votes") : _t("poll|end_message", { topAnswer });
const endEvent = PollEndEvent.from(this.props.event.getId()!, message).serialize();
await this.props.matrixClient.sendEvent(
this.props.event.getRoomId()!,
endEvent.type as keyof TimelineEvents,
endEvent.content as TimelineEvents[keyof TimelineEvents],
);
} catch (e) {
console.error("Failed to submit poll response event:", e);
Modal.createDialog(ErrorDialog, {
title: _t("poll|error_ending_title"),
description: _t("poll|error_ending_description"),
});
}
}
this.props.onFinished(endPoll);
};
public render(): React.ReactNode {
return (
<QuestionDialog
title={_t("poll|end_title")}
description={_t("poll|end_description")}
button={_t("poll|end_title")}
onFinished={(endPoll: boolean) => this.onFinished(endPoll)}
/>
);
}
}

View file

@ -0,0 +1,84 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
/*
* Usage:
* Modal.createDialog(ErrorDialog, {
* title: "some text", (default: "Error")
* description: "some more text",
* button: "Button Text",
* onFinished: someFunction,
* focus: true|false (default: true)
* });
*/
import React from "react";
import { _t, UserFriendlyError } from "../../../languageHandler";
import BaseDialog from "./BaseDialog";
/**
* Get a user friendly error message string from a given error. Useful for the
* `description` prop of the `ErrorDialog`
* @param err Error object in question to extract a useful message from. To make it easy
* to use with try/catch, this is typed as `any` because try/catch will type
* the error as `unknown`. And in any case we can use the fallback message.
* @param translatedFallbackMessage The fallback message to be used if the error doesn't have any message
* @returns a user friendly error message string from a given error
*/
export function extractErrorMessageFromError(err: any, translatedFallbackMessage: string): string {
return (
(err instanceof UserFriendlyError && err.translatedMessage) ||
(err instanceof Error && err.message) ||
translatedFallbackMessage
);
}
interface IProps {
onFinished: (success?: boolean) => void;
title?: string;
description?: React.ReactNode;
button?: string;
focus?: boolean;
headerImage?: string;
}
interface IState {
onFinished: (success: boolean) => void;
}
export default class ErrorDialog extends React.Component<IProps, IState> {
public static defaultProps: Partial<IProps> = {
focus: true,
};
private onClick = (): void => {
this.props.onFinished(true);
};
public render(): React.ReactNode {
return (
<BaseDialog
className="mx_ErrorDialog"
onFinished={this.props.onFinished}
title={this.props.title || _t("common|error")}
headerImage={this.props.headerImage}
contentId="mx_Dialog_content"
>
<div className="mx_Dialog_content" id="mx_Dialog_content">
{this.props.description || _t("error|dialog_description_default")}
</div>
<div className="mx_Dialog_buttons">
<button className="mx_Dialog_primary" onClick={this.onClick} autoFocus={this.props.focus}>
{this.props.button || _t("action|ok")}
</button>
</div>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,415 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useRef, useState, Dispatch, SetStateAction } from "react";
import { Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../languageHandler";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import Field from "../elements/Field";
import StyledRadioGroup from "../elements/StyledRadioGroup";
import StyledCheckbox from "../elements/StyledCheckbox";
import {
ExportFormat,
ExportFormatKey,
ExportType,
ExportTypeKey,
textForFormat,
textForType,
} from "../../../utils/exportUtils/exportUtils";
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
import HTMLExporter from "../../../utils/exportUtils/HtmlExport";
import JSONExporter from "../../../utils/exportUtils/JSONExport";
import PlainTextExporter from "../../../utils/exportUtils/PlainTextExport";
import { useStateCallback } from "../../../hooks/useStateCallback";
import Exporter from "../../../utils/exportUtils/Exporter";
import Spinner from "../elements/Spinner";
import InfoDialog from "./InfoDialog";
import ChatExport from "../../../customisations/ChatExport";
import { validateNumberInRange } from "../../../utils/validate";
interface IProps {
room: Room;
onFinished(doExport?: boolean): void;
}
interface ExportConfig {
exportFormat: ExportFormat;
exportType: ExportType;
numberOfMessages: number;
sizeLimit: number;
includeAttachments: boolean;
setExportFormat?: Dispatch<SetStateAction<ExportFormat>>;
setExportType?: Dispatch<SetStateAction<ExportType>>;
setAttachments?: Dispatch<SetStateAction<boolean>>;
setNumberOfMessages?: Dispatch<SetStateAction<number>>;
setSizeLimit?: Dispatch<SetStateAction<number>>;
}
/**
* Set up form state using "forceRoomExportParameters" or defaults
* Form fields configured in ForceRoomExportParameters are not allowed to be edited
* Only return change handlers for editable values
*/
const useExportFormState = (): ExportConfig => {
const config = ChatExport.getForceChatExportParameters();
const [exportFormat, setExportFormat] = useState(config.format ?? ExportFormat.Html);
const [exportType, setExportType] = useState(config.range ?? ExportType.Timeline);
const [includeAttachments, setAttachments] = useState(config.includeAttachments ?? false);
const [numberOfMessages, setNumberOfMessages] = useState<number>(config.numberOfMessages ?? 100);
const [sizeLimit, setSizeLimit] = useState<number>(config.sizeMb ?? 8);
return {
exportFormat,
exportType,
includeAttachments,
numberOfMessages,
sizeLimit,
setExportFormat: !config.format ? setExportFormat : undefined,
setExportType: !config.range ? setExportType : undefined,
setNumberOfMessages: !config.numberOfMessages ? setNumberOfMessages : undefined,
setSizeLimit: !config.sizeMb ? setSizeLimit : undefined,
setAttachments: config.includeAttachments === undefined ? setAttachments : undefined,
};
};
const ExportDialog: React.FC<IProps> = ({ room, onFinished }) => {
const {
exportFormat,
exportType,
includeAttachments,
numberOfMessages,
sizeLimit,
setExportFormat,
setExportType,
setNumberOfMessages,
setSizeLimit,
setAttachments,
} = useExportFormState();
const [isExporting, setExporting] = useState(false);
const sizeLimitRef = useRef<Field>(null);
const messageCountRef = useRef<Field>(null);
const [exportProgressText, setExportProgressText] = useState(_t("export_chat|processing"));
const [displayCancel, setCancelWarning] = useState(false);
const [exportCancelled, setExportCancelled] = useState(false);
const [exportSuccessful, setExportSuccessful] = useState(false);
const [exporter, setExporter] = useStateCallback<Exporter | null>(
null,
async (exporter: Exporter | null): Promise<void> => {
await exporter?.export().then(() => {
if (!exportCancelled) setExportSuccessful(true);
});
},
);
const startExport = async (): Promise<void> => {
const exportOptions = {
numberOfMessages,
attachmentsIncluded: includeAttachments,
maxSize: sizeLimit * 1024 * 1024,
};
switch (exportFormat) {
case ExportFormat.Html:
setExporter(new HTMLExporter(room, ExportType[exportType], exportOptions, setExportProgressText));
break;
case ExportFormat.Json:
setExporter(new JSONExporter(room, ExportType[exportType], exportOptions, setExportProgressText));
break;
case ExportFormat.PlainText:
setExporter(new PlainTextExporter(room, ExportType[exportType], exportOptions, setExportProgressText));
break;
default:
logger.error("Unknown export format");
return;
}
};
const onExportClick = async (): Promise<void> => {
const isValidSize =
!setSizeLimit ||
(await sizeLimitRef.current?.validate({
focused: false,
}));
if (!isValidSize) {
sizeLimitRef.current?.validate({ focused: true });
return;
}
if (exportType === ExportType.LastNMessages) {
const isValidNumberOfMessages = await messageCountRef.current?.validate({ focused: false });
if (!isValidNumberOfMessages) {
messageCountRef.current?.validate({ focused: true });
return;
}
}
setExporting(true);
await startExport();
};
const validateSize = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => {
const min = 1;
const max = 2000;
return _t("export_chat|enter_number_between_min_max", {
min,
max,
});
},
},
{
key: "number",
test: ({ value }) => {
const parsedSize = parseInt(value!, 10);
return validateNumberInRange(1, 2000)(parsedSize);
},
invalid: () => {
const min = 1;
const max = 2000;
return _t("export_chat|size_limit_min_max", { min, max });
},
},
],
});
const onValidateSize = async (fieldState: IFieldState): Promise<IValidationResult> => {
const result = await validateSize(fieldState);
return result;
};
const validateNumberOfMessages = withValidation({
rules: [
{
key: "required",
test({ value, allowEmpty }) {
return allowEmpty || !!value;
},
invalid: () => {
const min = 1;
const max = 10 ** 8;
return _t("export_chat|enter_number_between_min_max", {
min,
max,
});
},
},
{
key: "number",
test: ({ value }) => {
const parsedSize = parseInt(value!, 10);
return validateNumberInRange(1, 10 ** 8)(parsedSize);
},
invalid: () => {
const min = 1;
const max = 10 ** 8;
return _t("export_chat|num_messages_min_max", { min, max });
},
},
],
});
const onValidateNumberOfMessages = async (fieldState: IFieldState): Promise<IValidationResult> => {
const result = await validateNumberOfMessages(fieldState);
return result;
};
const onCancel = async (): Promise<void> => {
if (isExporting) setCancelWarning(true);
else onFinished(false);
};
const confirmCancel = async (): Promise<void> => {
await exporter?.cancelExport();
setExportCancelled(true);
setExporting(false);
setExporter(null);
};
const exportFormatOptions = Object.values(ExportFormat).map((format) => ({
value: format,
label: textForFormat(format),
}));
const exportTypeOptions = Object.values(ExportType).map((type) => {
return (
<option key={ExportType[type]} value={type}>
{textForType(type)}
</option>
);
});
let messageCount: JSX.Element | undefined;
if (exportType === ExportType.LastNMessages && setNumberOfMessages) {
messageCount = (
<Field
id="message-count"
element="input"
type="number"
value={numberOfMessages.toString()}
ref={messageCountRef}
onValidate={onValidateNumberOfMessages}
label={_t("export_chat|num_messages")}
onChange={(e) => {
setNumberOfMessages(parseInt(e.target.value));
}}
/>
);
}
const sizePostFix = <span>{_t("export_chat|size_limit_postfix")}</span>;
if (exportCancelled) {
// Display successful cancellation message
return (
<InfoDialog
title={_t("export_chat|cancelled")}
description={_t("export_chat|cancelled_detail")}
hasCloseButton={true}
onFinished={onFinished}
/>
);
} else if (exportSuccessful) {
// Display successful export message
return (
<InfoDialog
title={_t("export_chat|successful")}
description={_t("export_chat|successful_detail")}
hasCloseButton={true}
onFinished={onFinished}
/>
);
} else if (displayCancel) {
// Display cancel warning
return (
<BaseDialog
title={_t("common|warning")}
className="mx_ExportDialog"
contentId="mx_Dialog_content"
onFinished={onFinished}
fixedWidth={true}
>
<p>{_t("export_chat|confirm_stop")}</p>
<DialogButtons
primaryButton={_t("action|stop")}
primaryButtonClass="danger"
hasCancel={true}
cancelButton={_t("action|continue")}
onCancel={() => setCancelWarning(false)}
onPrimaryButtonClick={confirmCancel}
/>
</BaseDialog>
);
} else {
// Display export settings
return (
<BaseDialog
title={isExporting ? _t("export_chat|exporting_your_data") : _t("export_chat|title")}
className={`mx_ExportDialog ${isExporting && "mx_ExportDialog_Exporting"}`}
contentId="mx_Dialog_content"
hasCancel={true}
onFinished={onFinished}
fixedWidth={true}
>
{!isExporting ? <p>{_t("export_chat|select_option")}</p> : null}
<div className="mx_ExportDialog_options">
{!!setExportFormat && (
<>
<span className="mx_ExportDialog_subheading">{_t("export_chat|format")}</span>
<StyledRadioGroup
name="exportFormat"
value={exportFormat}
onChange={(key: ExportFormatKey) => setExportFormat(ExportFormat[key])}
definitions={exportFormatOptions}
/>
</>
)}
{!!setExportType && (
<>
<span className="mx_ExportDialog_subheading">{_t("export_chat|messages")}</span>
<Field
id="export-type"
element="select"
value={exportType}
onChange={(e) => {
setExportType(ExportType[e.target.value as ExportTypeKey]);
}}
>
{exportTypeOptions}
</Field>
{messageCount}
</>
)}
{setSizeLimit && (
<>
<span className="mx_ExportDialog_subheading">{_t("export_chat|size_limit")}</span>
<Field
id="size-limit"
type="number"
autoComplete="off"
onValidate={onValidateSize}
element="input"
ref={sizeLimitRef}
value={sizeLimit.toString()}
postfixComponent={sizePostFix}
onChange={(e) => setSizeLimit(parseInt(e.target.value))}
/>
</>
)}
{setAttachments && (
<>
<StyledCheckbox
className="mx_ExportDialog_attachments-checkbox"
id="include-attachments"
checked={includeAttachments}
onChange={(e) => setAttachments((e.target as HTMLInputElement).checked)}
>
{_t("export_chat|include_attachments")}
</StyledCheckbox>
</>
)}
</div>
{isExporting ? (
<div data-testid="export-progress" className="mx_ExportDialog_progress">
<Spinner w={24} h={24} />
<p>{exportProgressText}</p>
<DialogButtons
primaryButton={_t("action|cancel")}
primaryButtonClass="danger"
hasCancel={false}
onPrimaryButtonClick={onCancel}
/>
</div>
) : (
<DialogButtons
primaryButton={_t("action|export")}
onPrimaryButtonClick={onExportClick}
onCancel={() => onFinished(false)}
/>
)}
</BaseDialog>
);
}
};
export default ExportDialog;

View file

@ -0,0 +1,154 @@
/*
Copyright 2018-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.
*/
import React, { useEffect, useRef, useState } from "react";
import QuestionDialog from "./QuestionDialog";
import { _t } from "../../../languageHandler";
import Field from "../elements/Field";
import AccessibleButton from "../elements/AccessibleButton";
import SdkConfig from "../../../SdkConfig";
import Modal from "../../../Modal";
import BugReportDialog from "./BugReportDialog";
import InfoDialog from "./InfoDialog";
import { submitFeedback } from "../../../rageshake/submit-rageshake";
import { useStateToggle } from "../../../hooks/useStateToggle";
import StyledCheckbox from "../elements/StyledCheckbox";
import ExternalLink from "../elements/ExternalLink";
interface IProps {
feature?: string;
onFinished(): void;
}
const FeedbackDialog: React.FC<IProps> = (props: IProps) => {
const feedbackRef = useRef<Field>(null);
const [comment, setComment] = useState<string>("");
const [canContact, toggleCanContact] = useStateToggle(false);
useEffect(() => {
// autofocus doesn't work on textareas
feedbackRef.current?.focus();
}, []);
const onDebugLogsLinkClick = (): void => {
props.onFinished();
Modal.createDialog(BugReportDialog, {});
};
const hasFeedback = !!SdkConfig.get().bug_report_endpoint_url;
const onFinished = (sendFeedback: boolean): void => {
if (hasFeedback && sendFeedback) {
const label = props.feature ? `${props.feature}-feedback` : "feedback";
submitFeedback(label, comment, canContact);
Modal.createDialog(InfoDialog, {
title: _t("feedback|sent"),
description: _t("bug_reporting|thank_you"),
});
}
props.onFinished();
};
let feedbackSection: JSX.Element | undefined;
if (hasFeedback) {
feedbackSection = (
<div className="mx_FeedbackDialog_section mx_FeedbackDialog_rateApp">
<h3>{_t("feedback|comment_label")}</h3>
<p>{_t("feedback|platform_username")}</p>
<Field
id="feedbackComment"
label={_t("common|feedback")}
type="text"
autoComplete="off"
value={comment}
element="textarea"
onChange={(ev) => {
setComment(ev.target.value);
}}
ref={feedbackRef}
/>
<StyledCheckbox checked={canContact} onChange={toggleCanContact}>
{_t("feedback|may_contact_label")}
</StyledCheckbox>
</div>
);
}
let bugReports: JSX.Element | undefined;
if (hasFeedback) {
bugReports = (
<p className="mx_FeedbackDialog_section_microcopy">
{_t(
"feedback|pro_type",
{},
{
debugLogsLink: (sub) => (
<AccessibleButton kind="link_inline" onClick={onDebugLogsLinkClick}>
{sub}
</AccessibleButton>
),
},
)}
</p>
);
}
const existingIssuesUrl = SdkConfig.getObject("feedback").get("existing_issues_url");
const newIssueUrl = SdkConfig.getObject("feedback").get("new_issue_url");
return (
<QuestionDialog
className="mx_FeedbackDialog"
hasCancelButton={hasFeedback}
title={_t("common|feedback")}
description={
<React.Fragment>
<div className="mx_FeedbackDialog_section mx_FeedbackDialog_reportBug">
<h3>{_t("common|report_a_bug")}</h3>
<p>
{_t(
"feedback|existing_issue_link",
{},
{
existingIssuesLink: (sub) => {
return (
<ExternalLink
target="_blank"
rel="noreferrer noopener"
href={existingIssuesUrl}
>
{sub}
</ExternalLink>
);
},
newIssueLink: (sub) => {
return (
<ExternalLink target="_blank" rel="noreferrer noopener" href={newIssueUrl}>
{sub}
</ExternalLink>
);
},
},
)}
</p>
{bugReports}
</div>
{feedbackSection}
</React.Fragment>
}
button={hasFeedback ? _t("feedback|send_feedback_action") : _t("action|go_back")}
buttonDisabled={hasFeedback && !comment}
onFinished={onFinished}
/>
);
};
export default FeedbackDialog;

View file

@ -0,0 +1,405 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 Robin Townsend <robin@robin.town>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useEffect, useMemo, useState } from "react";
import classnames from "classnames";
import {
IContent,
MatrixEvent,
Room,
RoomMember,
EventType,
MatrixClient,
ContentHelpers,
ILocationContent,
LocationAssetType,
M_TIMESTAMP,
M_BEACON,
TimelineEvents,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import { useSettingValue } from "../../../hooks/useSettings";
import { Layout } from "../../../settings/enums/Layout";
import BaseDialog from "./BaseDialog";
import { avatarUrlForUser } from "../../../Avatar";
import EventTile from "../rooms/EventTile";
import SearchBox from "../../structures/SearchBox";
import DecoratedRoomAvatar from "../avatars/DecoratedRoomAvatar";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import { StaticNotificationState } from "../../../stores/notifications/StaticNotificationState";
import NotificationBadge from "../rooms/NotificationBadge";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { sortRooms } from "../../../stores/room-list/algorithms/tag-sorting/RecentAlgorithm";
import QueryMatcher from "../../../autocomplete/QueryMatcher";
import TruncatedList from "../elements/TruncatedList";
import EntityTile from "../rooms/EntityTile";
import BaseAvatar from "../avatars/BaseAvatar";
import { Action } from "../../../dispatcher/actions";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import { isLocationEvent } from "../../../utils/EventUtils";
import { isSelfLocation, locationEventGeoUri } from "../../../utils/location";
import { RoomContextDetails } from "../rooms/RoomContextDetails";
import { filterBoolean } from "../../../utils/arrays";
import {
IState,
RovingTabIndexContext,
RovingTabIndexProvider,
Type,
useRovingTabIndex,
} from "../../../accessibility/RovingTabIndex";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
const AVATAR_SIZE = 30;
interface IProps {
matrixClient: MatrixClient;
// The event to forward
event: MatrixEvent;
// We need a permalink creator for the source room to pass through to EventTile
// in case the event is a reply (even though the user can't get at the link)
permalinkCreator: RoomPermalinkCreator;
onFinished(): void;
}
interface IEntryProps<K extends keyof TimelineEvents> {
room: Room;
type: K;
content: TimelineEvents[K];
matrixClient: MatrixClient;
onFinished(success: boolean): void;
}
enum SendState {
CanSend,
Sending,
Sent,
Failed,
}
const Entry: React.FC<IEntryProps<any>> = ({ room, type, content, matrixClient: cli, onFinished }) => {
const [sendState, setSendState] = useState<SendState>(SendState.CanSend);
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLDivElement>();
const jumpToRoom = (ev: ButtonEvent): void => {
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: room.roomId,
metricsTrigger: "WebForwardShortcut",
metricsViaKeyboard: ev.type !== "click",
});
onFinished(true);
};
const send = async (): Promise<void> => {
setSendState(SendState.Sending);
try {
await cli.sendEvent(room.roomId, type, content);
setSendState(SendState.Sent);
} catch (e) {
setSendState(SendState.Failed);
}
};
let className;
let disabled = false;
let title;
let icon;
if (sendState === SendState.CanSend) {
className = "mx_ForwardList_canSend";
if (!room.maySendMessage()) {
disabled = true;
title = _t("forward|no_perms_title");
}
} else if (sendState === SendState.Sending) {
className = "mx_ForwardList_sending";
disabled = true;
title = _t("forward|sending");
icon = <div className="mx_ForwardList_sendIcon" aria-label={title} />;
} else if (sendState === SendState.Sent) {
className = "mx_ForwardList_sent";
disabled = true;
title = _t("forward|sent");
icon = <div className="mx_ForwardList_sendIcon" aria-label={title} />;
} else {
className = "mx_ForwardList_sendFailed";
disabled = true;
title = _t("timeline|send_state_failed");
icon = <NotificationBadge notification={StaticNotificationState.RED_EXCLAMATION} />;
}
const id = `mx_ForwardDialog_entry_${room.roomId}`;
return (
<div
className={classnames("mx_ForwardList_entry", {
mx_ForwardList_entry_active: isActive,
})}
aria-labelledby={`${id}_name`}
aria-describedby={`${id}_send`}
role="listitem"
ref={ref}
onFocus={onFocus}
id={id}
>
<AccessibleButton
className="mx_ForwardList_roomButton"
onClick={jumpToRoom}
title={_t("forward|open_room")}
placement="top"
tabIndex={isActive ? 0 : -1}
>
<DecoratedRoomAvatar room={room} size="32px" tooltipProps={{ tabIndex: isActive ? 0 : -1 }} />
<span className="mx_ForwardList_entry_name" id={`${id}_name`}>
{room.name}
</span>
<RoomContextDetails component="span" className="mx_ForwardList_entry_detail" room={room} />
</AccessibleButton>
<AccessibleButton
kind={sendState === SendState.Failed ? "danger_outline" : "primary_outline"}
className={`mx_ForwardList_sendButton ${className}`}
onClick={send}
disabled={disabled}
title={title}
placement="top"
tabIndex={isActive ? 0 : -1}
id={`${id}_send`}
>
<div className="mx_ForwardList_sendLabel">{_t("forward|send_label")}</div>
{icon}
</AccessibleButton>
</div>
);
};
const transformEvent = (event: MatrixEvent): { type: string; content: IContent } => {
const {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
"m.relates_to": _, // strip relations - in future we will attach a relation pointing at the original event
// We're taking a shallow copy here to avoid https://github.com/vector-im/element-web/issues/10924
...content
} = event.getContent();
// beacon pulses get transformed into static locations on forward
const type = M_BEACON.matches(event.getType()) ? EventType.RoomMessage : event.getType();
// self location shares should have their description removed
// and become 'pin' share type
if (
(isLocationEvent(event) && isSelfLocation(content as ILocationContent)) ||
// beacon pulses get transformed into static locations on forward
M_BEACON.matches(event.getType())
) {
const timestamp = M_TIMESTAMP.findIn<number>(content);
const geoUri = locationEventGeoUri(event);
return {
type,
content: {
...content,
...ContentHelpers.makeLocationContent(
undefined, // text
geoUri,
timestamp || Date.now(),
undefined, // description
LocationAssetType.Pin,
),
},
};
}
return { type, content };
};
const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCreator, onFinished }) => {
const userId = cli.getSafeUserId();
const [profileInfo, setProfileInfo] = useState<any>({});
useEffect(() => {
cli.getProfileInfo(userId).then((info) => setProfileInfo(info));
}, [cli, userId]);
const { type, content } = transformEvent(event);
// For the message preview we fake the sender as ourselves
const mockEvent = new MatrixEvent({
type: "m.room.message",
sender: userId,
content,
unsigned: {
age: 97,
},
event_id: "$9999999999999999999999999999999999999999999",
room_id: event.getRoomId(),
origin_server_ts: event.getTs(),
});
mockEvent.sender = {
name: profileInfo.displayname || userId,
rawDisplayName: profileInfo.displayname,
userId,
getAvatarUrl: (..._) => {
return avatarUrlForUser({ avatarUrl: profileInfo.avatar_url }, AVATAR_SIZE, AVATAR_SIZE, "crop");
},
getMxcAvatarUrl: () => profileInfo.avatar_url,
} as RoomMember;
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase();
const previewLayout = useSettingValue<Layout>("layout");
const msc3946DynamicRoomPredecessors = useSettingValue<boolean>("feature_dynamic_room_predecessors");
let rooms = useMemo(
() =>
sortRooms(
cli
.getVisibleRooms(msc3946DynamicRoomPredecessors)
.filter((room) => room.getMyMembership() === KnownMembership.Join && !room.isSpaceRoom()),
),
[cli, msc3946DynamicRoomPredecessors],
);
if (lcQuery) {
rooms = new QueryMatcher<Room>(rooms, {
keys: ["name"],
funcs: [(r) => filterBoolean([r.getCanonicalAlias(), ...r.getAltAliases()])],
shouldMatchWordsOnly: false,
}).match(lcQuery);
}
const [truncateAt, setTruncateAt] = useState(20);
function overflowTile(overflowCount: number, totalCount: number): JSX.Element {
const text = _t("common|and_n_others", { count: overflowCount });
return (
<EntityTile
className="mx_EntityTile_ellipsis"
avatarJsx={
<BaseAvatar url={require("../../../../res/img/ellipsis.svg").default} name="..." size="36px" />
}
name={text}
showPresence={false}
onClick={() => setTruncateAt(totalCount)}
/>
);
}
const onKeyDown = (ev: React.KeyboardEvent, state: IState): void => {
let handled = true;
const action = getKeyBindingsManager().getAccessibilityAction(ev);
switch (action) {
case KeyBindingAction.Enter: {
state.activeRef?.current?.querySelector<HTMLButtonElement>(".mx_ForwardList_sendButton")?.click();
break;
}
default:
handled = false;
}
if (handled) {
ev.preventDefault();
ev.stopPropagation();
}
};
return (
<BaseDialog
title={_t("common|forward_message")}
className="mx_ForwardDialog"
contentId="mx_ForwardList"
onFinished={onFinished}
fixedWidth={false}
>
<h3>{_t("forward|message_preview_heading")}</h3>
<div
className={classnames("mx_ForwardDialog_preview", {
mx_IRCLayout: previewLayout == Layout.IRC,
})}
>
<EventTile
mxEvent={mockEvent}
layout={previewLayout}
permalinkCreator={permalinkCreator}
as="div"
inhibitInteraction
/>
</div>
<hr />
<RovingTabIndexProvider
handleUpDown
handleInputFields
onKeyDown={onKeyDown}
scrollIntoView={{ block: "center" }}
>
{({ onKeyDownHandler }) => (
<div className="mx_ForwardList" id="mx_ForwardList">
<RovingTabIndexContext.Consumer>
{(context) => (
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={_t("forward|filter_placeholder")}
onSearch={(query: string): void => {
setQuery(query);
setTimeout(() => {
const ref = context.state.refs[0];
if (ref) {
context.dispatch({
type: Type.SetFocus,
payload: { ref },
});
ref.current?.scrollIntoView?.({
block: "nearest",
});
}
});
}}
autoFocus={true}
onKeyDown={onKeyDownHandler}
aria-activedescendant={context.state.activeRef?.current?.id}
aria-owns="mx_ForwardDialog_resultsList"
/>
)}
</RovingTabIndexContext.Consumer>
<AutoHideScrollbar className="mx_ForwardList_content">
{rooms.length > 0 ? (
<div className="mx_ForwardList_results">
<TruncatedList
id="mx_ForwardDialog_resultsList"
className="mx_ForwardList_resultsList"
truncateAt={truncateAt}
createOverflowElement={overflowTile}
getChildren={(start, end) =>
rooms
.slice(start, end)
.map((room) => (
<Entry
key={room.roomId}
room={room}
type={type}
content={content}
matrixClient={cli}
onFinished={onFinished}
/>
))
}
getChildCount={() => rooms.length}
/>
</div>
) : (
<span className="mx_ForwardList_noResults">{_t("common|no_results")}</span>
)}
</AutoHideScrollbar>
</div>
)}
</RovingTabIndexProvider>
</BaseDialog>
);
};
export default ForwardDialog;

View file

@ -0,0 +1,97 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ReactNode, useState } from "react";
import QuestionDialog from "./QuestionDialog";
import { _t } from "../../../languageHandler";
import Field from "../elements/Field";
import { submitFeedback } from "../../../rageshake/submit-rageshake";
import StyledCheckbox from "../elements/StyledCheckbox";
import Modal from "../../../Modal";
import InfoDialog from "./InfoDialog";
interface IProps {
title: string;
subheading?: string;
rageshakeLabel?: string;
rageshakeData?: Record<string, any>;
children?: ReactNode;
onFinished(sendFeedback?: boolean): void;
}
const GenericFeatureFeedbackDialog: React.FC<IProps> = ({
title,
subheading,
children,
rageshakeLabel,
rageshakeData = {},
onFinished,
}) => {
const [comment, setComment] = useState("");
const [canContact, setCanContact] = useState(false);
const sendFeedback = async (ok: boolean): Promise<void> => {
if (!ok) return onFinished(false);
submitFeedback(rageshakeLabel, comment, canContact, rageshakeData);
onFinished(true);
Modal.createDialog(InfoDialog, {
title,
description: _t("feedback|sent"),
button: _t("action|close"),
hasCloseButton: false,
fixedWidth: false,
});
};
return (
<QuestionDialog
className="mx_GenericFeatureFeedbackDialog"
hasCancelButton={true}
title={title}
description={
<React.Fragment>
<div className="mx_GenericFeatureFeedbackDialog_subheading">
{subheading}
&nbsp;
{_t("feedback|platform_username")}
&nbsp;
{children}
</div>
<Field
id="feedbackComment"
label={_t("common|feedback")}
type="text"
autoComplete="off"
value={comment}
element="textarea"
onChange={(ev) => {
setComment(ev.target.value);
}}
autoFocus={true}
/>
<StyledCheckbox
checked={canContact}
onChange={(e) => setCanContact((e.target as HTMLInputElement).checked)}
>
{_t("feedback|can_contact_label")}
</StyledCheckbox>
</React.Fragment>
}
button={_t("feedback|send_feedback_action")}
buttonDisabled={!comment}
onFinished={sendFeedback}
/>
);
};
export default GenericFeatureFeedbackDialog;

View file

@ -0,0 +1,259 @@
/*
Copyright 2019-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.
*/
import React, { ReactNode } from "react";
import { GeneratedSas, ShowSasCallbacks, Verifier, VerifierEvent } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { _t } from "../../../languageHandler";
import { mediaFromMxc } from "../../../customisations/Media";
import VerificationComplete from "../verification/VerificationComplete";
import VerificationCancelled from "../verification/VerificationCancelled";
import BaseAvatar from "../avatars/BaseAvatar";
import Spinner from "../elements/Spinner";
import VerificationShowSas from "../verification/VerificationShowSas";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
const PHASE_START = 0;
const PHASE_SHOW_SAS = 1;
const PHASE_WAIT_FOR_PARTNER_TO_CONFIRM = 2;
const PHASE_VERIFIED = 3;
const PHASE_CANCELLED = 4;
interface IProps {
verifier: Verifier;
onFinished(verified?: boolean): void;
}
interface IState {
phase: number;
sasVerified: boolean;
opponentProfile: {
// eslint-disable-next-line camelcase
avatar_url?: string;
displayname?: string;
} | null;
opponentProfileError: Error | null;
sas: GeneratedSas | null;
}
export default class IncomingSasDialog extends React.Component<IProps, IState> {
private showSasEvent: ShowSasCallbacks | null;
public constructor(props: IProps) {
super(props);
let phase = PHASE_START;
if (this.props.verifier.hasBeenCancelled) {
logger.log("Verifier was cancelled in the background.");
phase = PHASE_CANCELLED;
}
this.showSasEvent = null;
this.state = {
phase: phase,
sasVerified: false,
opponentProfile: null,
opponentProfileError: null,
sas: null,
};
this.props.verifier.on(VerifierEvent.ShowSas, this.onVerifierShowSas);
this.props.verifier.on(VerifierEvent.Cancel, this.onVerifierCancel);
this.fetchOpponentProfile();
}
public componentWillUnmount(): void {
if (this.state.phase !== PHASE_CANCELLED && this.state.phase !== PHASE_VERIFIED) {
this.props.verifier.cancel(new Error("User cancel"));
}
this.props.verifier.removeListener(VerifierEvent.ShowSas, this.onVerifierShowSas);
}
private async fetchOpponentProfile(): Promise<void> {
try {
const prof = await MatrixClientPeg.safeGet().getProfileInfo(this.props.verifier.userId);
this.setState({
opponentProfile: prof,
});
} catch (e) {
this.setState({
opponentProfileError: e as Error,
});
}
}
private onFinished = (): void => {
this.props.onFinished(this.state.phase === PHASE_VERIFIED);
};
private onCancelClick = (): void => {
this.props.onFinished(this.state.phase === PHASE_VERIFIED);
};
private onContinueClick = (): void => {
this.setState({ phase: PHASE_WAIT_FOR_PARTNER_TO_CONFIRM });
this.props.verifier
.verify()
.then(() => {
this.setState({ phase: PHASE_VERIFIED });
})
.catch((e) => {
logger.log("Verification failed", e);
});
};
private onVerifierShowSas = (e: ShowSasCallbacks): void => {
this.showSasEvent = e;
this.setState({
phase: PHASE_SHOW_SAS,
sas: e.sas,
});
};
private onVerifierCancel = (): void => {
this.setState({
phase: PHASE_CANCELLED,
});
};
private onSasMatchesClick = (): void => {
this.showSasEvent?.confirm();
this.setState({
phase: PHASE_WAIT_FOR_PARTNER_TO_CONFIRM,
});
};
private onVerifiedDoneClick = (): void => {
this.props.onFinished(true);
};
private renderPhaseStart(): ReactNode {
const isSelf = this.props.verifier.userId === MatrixClientPeg.safeGet().getUserId();
let profile;
const oppProfile = this.state.opponentProfile;
if (oppProfile) {
const url = oppProfile.avatar_url ? mediaFromMxc(oppProfile.avatar_url).getSquareThumbnailHttp(48) : null;
profile = (
<div className="mx_IncomingSasDialog_opponentProfile">
<BaseAvatar
name={oppProfile.displayname}
idName={this.props.verifier.userId}
url={url}
size="48px"
/>
<h2>{oppProfile.displayname}</h2>
</div>
);
} else if (this.state.opponentProfileError) {
profile = (
<div>
<BaseAvatar
name={this.props.verifier.userId.slice(1)}
idName={this.props.verifier.userId}
size="48px"
/>
<h2>{this.props.verifier.userId}</h2>
</div>
);
} else {
profile = <Spinner />;
}
const userDetailText = [
<p key="p1">{_t("encryption|verification|incoming_sas_user_dialog_text_1")}</p>,
<p key="p2">
{_t(
// NB. Below wording adjusted to singular 'session' until we have
// cross-signing
"encryption|verification|incoming_sas_user_dialog_text_2",
)}
</p>,
];
const selfDetailText = [
<p key="p1">{_t("encryption|verification|incoming_sas_device_dialog_text_1")}</p>,
<p key="p2">{_t("encryption|verification|incoming_sas_device_dialog_text_2")}</p>,
];
return (
<div>
{profile}
{isSelf ? selfDetailText : userDetailText}
<DialogButtons
primaryButton={_t("action|continue")}
hasCancel={true}
onPrimaryButtonClick={this.onContinueClick}
onCancel={this.onCancelClick}
/>
</div>
);
}
private renderPhaseShowSas(): ReactNode {
if (!this.showSasEvent) return null;
return (
<VerificationShowSas
sas={this.showSasEvent.sas}
onCancel={this.onCancelClick}
onDone={this.onSasMatchesClick}
isSelf={this.props.verifier.userId === MatrixClientPeg.safeGet().getUserId()}
inDialog={true}
/>
);
}
private renderPhaseWaitForPartnerToConfirm(): ReactNode {
return (
<div>
<Spinner />
<p>{_t("encryption|verification|incoming_sas_dialog_waiting")}</p>
</div>
);
}
private renderPhaseVerified(): ReactNode {
return <VerificationComplete onDone={this.onVerifiedDoneClick} />;
}
private renderPhaseCancelled(): ReactNode {
return <VerificationCancelled onDone={this.onCancelClick} />;
}
public render(): ReactNode {
let body;
switch (this.state.phase) {
case PHASE_START:
body = this.renderPhaseStart();
break;
case PHASE_SHOW_SAS:
body = this.renderPhaseShowSas();
break;
case PHASE_WAIT_FOR_PARTNER_TO_CONFIRM:
body = this.renderPhaseWaitForPartnerToConfirm();
break;
case PHASE_VERIFIED:
body = this.renderPhaseVerified();
break;
case PHASE_CANCELLED:
body = this.renderPhaseCancelled();
break;
}
return (
<BaseDialog
title={_t("encryption|verification|incoming_sas_dialog_title")}
onFinished={this.onFinished}
fixedWidth={false}
>
{body}
</BaseDialog>
);
}
}

View file

@ -0,0 +1,65 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
Copyright 2019 Bastian Masanek, Noxware IT <matrix@noxware.de>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ReactNode } from "react";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
interface IProps {
top?: ReactNode;
title?: string;
description?: ReactNode;
className?: string;
button?: boolean | string;
hasCloseButton?: boolean;
fixedWidth?: boolean;
onKeyDown?(event: KeyboardEvent | React.KeyboardEvent): void;
onFinished(): void;
}
export default class InfoDialog extends React.Component<IProps> {
public static defaultProps: Partial<IProps> = {
title: "",
description: "",
hasCloseButton: false,
};
private onFinished = (): void => {
this.props.onFinished();
};
public render(): React.ReactNode {
return (
<BaseDialog
className="mx_InfoDialog"
onFinished={this.props.onFinished}
top={this.props.top}
title={this.props.title}
contentId="mx_Dialog_content"
hasCancel={this.props.hasCloseButton}
onKeyDown={this.props.onKeyDown}
fixedWidth={this.props.fixedWidth}
>
<div className={classNames("mx_Dialog_content", this.props.className)} id="mx_Dialog_content">
{this.props.description}
</div>
{this.props.button !== false && (
<DialogButtons
primaryButton={this.props.button || _t("action|ok")}
onPrimaryButtonClick={this.onFinished}
hasCancel={false}
/>
)}
</BaseDialog>
);
}
}

View file

@ -0,0 +1,55 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { _t } from "../../../languageHandler";
import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
interface IProps {
onFinished(): void;
}
export default class IntegrationsDisabledDialog extends React.Component<IProps> {
private onAcknowledgeClick = (): void => {
this.props.onFinished();
};
private onOpenSettingsClick = (): void => {
this.props.onFinished();
dis.fire(Action.ViewUserSettings);
};
public render(): React.ReactNode {
return (
<BaseDialog
className="mx_IntegrationsDisabledDialog"
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("integrations|disabled_dialog_title")}
>
<div className="mx_IntegrationsDisabledDialog_content">
<p>
{_t("integrations|disabled_dialog_description", {
manageIntegrations: _t("integration_manager|manage_title"),
})}
</p>
</div>
<DialogButtons
primaryButton={_t("common|settings")}
onPrimaryButtonClick={this.onOpenSettingsClick}
cancelButton={_t("action|ok")}
onCancel={this.onAcknowledgeClick}
/>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,46 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
interface IProps {
onFinished(): void;
}
export default class IntegrationsImpossibleDialog extends React.Component<IProps> {
private onAcknowledgeClick = (): void => {
this.props.onFinished();
};
public render(): React.ReactNode {
const brand = SdkConfig.get().brand;
return (
<BaseDialog
className="mx_IntegrationsImpossibleDialog"
hasCancel={false}
onFinished={this.props.onFinished}
title={_t("integrations|impossible_dialog_title")}
>
<div className="mx_IntegrationsImpossibleDialog_content">
<p>{_t("integrations|impossible_dialog_description", { brand })}</p>
</div>
<DialogButtons
primaryButton={_t("action|ok")}
onPrimaryButtonClick={this.onAcknowledgeClick}
hasCancel={false}
/>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,201 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2017 Vector Creations Ltd
Copyright 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { MatrixClient, UIAResponse } from "matrix-js-sdk/src/matrix";
import { AuthType } from "matrix-js-sdk/src/interactive-auth";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import InteractiveAuth, {
ERROR_USER_CANCELLED,
InteractiveAuthCallback,
InteractiveAuthProps,
} from "../../structures/InteractiveAuth";
import { ContinueKind, SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents";
import BaseDialog from "./BaseDialog";
import { Linkify } from "../../../Linkify";
type DialogAesthetics = Partial<{
[x in AuthType]: {
[x: number]: {
title: string;
body: string;
continueText: string;
continueKind: ContinueKind;
};
};
}>;
export interface InteractiveAuthDialogProps<T = unknown>
extends Pick<InteractiveAuthProps<T>, "makeRequest" | "authData"> {
// matrix client to use for UI auth requests
matrixClient: MatrixClient;
// Optional title and body to show when not showing a particular stage
title?: string;
body?: string;
// Optional title and body pairs for particular stages and phases within
// those stages. Object structure/example is:
// {
// "org.example.stage_type": {
// 1: {
// "body": "This is a body for phase 1" of org.example.stage_type,
// "title": "Title for phase 1 of org.example.stage_type"
// },
// 2: {
// "body": "This is a body for phase 2 of org.example.stage_type",
// "title": "Title for phase 2 of org.example.stage_type"
// "continueText": "Confirm identity with Example Auth",
// "continueKind": "danger"
// }
// }
// }
//
// Default is defined in _getDefaultDialogAesthetics()
aestheticsForStagePhases?: DialogAesthetics;
onFinished(success?: boolean, result?: UIAResponse<T> | Error | null): void;
}
interface IState {
authError: Error | null;
// See _onUpdateStagePhase()
uiaStage: AuthType | null;
uiaStagePhase: number | null;
}
export default class InteractiveAuthDialog<T> extends React.Component<InteractiveAuthDialogProps<T>, IState> {
public constructor(props: InteractiveAuthDialogProps<T>) {
super(props);
this.state = {
authError: null,
// See _onUpdateStagePhase()
uiaStage: null,
uiaStagePhase: null,
};
}
private getDefaultDialogAesthetics(): DialogAesthetics {
const ssoAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("auth|uia|sso_title"),
body: _t("auth|uia|sso_preauth_body"),
continueText: _t("auth|sso"),
continueKind: "primary",
},
[SSOAuthEntry.PHASE_POSTAUTH]: {
title: _t("auth|uia|sso_postauth_title"),
body: _t("auth|uia|sso_postauth_body"),
continueText: _t("action|confirm"),
continueKind: "primary",
},
};
return {
[SSOAuthEntry.LOGIN_TYPE]: ssoAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: ssoAesthetics,
};
}
private onAuthFinished: InteractiveAuthCallback<T> = async (success, result): Promise<void> => {
if (success) {
this.props.onFinished(true, result);
} else {
if (result === ERROR_USER_CANCELLED) {
this.props.onFinished(false, null);
} else {
this.setState({
authError: result as Error,
});
}
}
};
private onUpdateStagePhase = (newStage: AuthType, newPhase: number): void => {
// We copy the stage and stage phase params into state for title selection in render()
this.setState({ uiaStage: newStage, uiaStagePhase: newPhase });
};
private onDismissClick = (): void => {
this.props.onFinished(false);
};
public render(): React.ReactNode {
// Let's pick a title, body, and other params text that we'll show to the user. The order
// is most specific first, so stagePhase > our props > defaults.
let title = this.state.authError ? "Error" : this.props.title || _t("common|authentication");
let body = this.state.authError ? null : this.props.body;
let continueText: string | undefined;
let continueKind: ContinueKind | undefined;
const dialogAesthetics = this.props.aestheticsForStagePhases || this.getDefaultDialogAesthetics();
if (!this.state.authError && dialogAesthetics) {
if (
this.state.uiaStage !== null &&
this.state.uiaStagePhase !== null &&
dialogAesthetics[this.state.uiaStage]
) {
const aesthetics = dialogAesthetics[this.state.uiaStage]![this.state.uiaStagePhase];
if (aesthetics) {
if (aesthetics.title) title = aesthetics.title;
if (aesthetics.body) body = aesthetics.body;
if (aesthetics.continueText) continueText = aesthetics.continueText;
if (aesthetics.continueKind) continueKind = aesthetics.continueKind;
}
}
}
let content: JSX.Element;
if (this.state.authError) {
content = (
<div id="mx_Dialog_content">
<Linkify>
<div role="alert">{this.state.authError.message || this.state.authError.toString()}</div>
</Linkify>
<br />
<AccessibleButton onClick={this.onDismissClick} className="mx_GeneralButton" autoFocus={true}>
{_t("action|dismiss")}
</AccessibleButton>
</div>
);
} else {
content = (
<div id="mx_Dialog_content">
{body}
<InteractiveAuth
matrixClient={this.props.matrixClient}
authData={this.props.authData}
makeRequest={this.props.makeRequest}
onAuthFinished={this.onAuthFinished}
onStagePhaseChange={this.onUpdateStagePhase}
continueText={continueText}
continueKind={continueKind}
/>
</div>
);
}
return (
<BaseDialog
className="mx_InteractiveAuthDialog"
onFinished={this.props.onFinished}
title={title}
contentId="mx_Dialog_content"
>
{content}
</BaseDialog>
);
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,16 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
export enum InviteKind {
Dm = "dm",
Invite = "invite",
// NB. This dialog needs the 'mx_InviteDialog_transferWrapper' wrapper class to have the correct
// padding on the bottom (because all modals have 24px padding on all sides), so this needs to
// be passed when creating the modal
CallTransfer = "call_transfer",
}

View file

@ -0,0 +1,114 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useMemo, useState } from "react";
import { Room, JoinRule } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "../dialogs/BaseDialog";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import SpaceChildrenPicker from "../spaces/SpaceChildrenPicker";
import { filterBoolean } from "../../../utils/arrays";
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));
SpaceStore.instance.traverseSpace(
space.roomId,
(spaceId) => {
if (space.roomId === spaceId) return; // skip the root node
roomSet.add(spaceId);
},
false,
);
return filterBoolean(Array.from(roomSet).map((roomId) => space.client.getRoom(roomId)));
}, [space]);
const [roomsToLeave, setRoomsToLeave] = useState<Room[]>([]);
const selectedRooms = useMemo(() => new Set(roomsToLeave), [roomsToLeave]);
let rejoinWarning;
if (space.getJoinRule() !== JoinRule.Public) {
rejoinWarning = _t("space|leave_dialog_public_rejoin_warning");
}
let onlyAdminWarning;
if (isOnlyAdmin(space)) {
onlyAdminWarning = _t("space|leave_dialog_only_admin_warning");
} else {
const numChildrenOnlyAdminIn = roomsToLeave.filter(isOnlyAdmin).length;
if (numChildrenOnlyAdminIn > 0) {
onlyAdminWarning = _t("space|leave_dialog_only_admin_room_warning");
}
}
return (
<BaseDialog
title={_t("space|leave_dialog_title", { spaceName: space.name })}
className="mx_LeaveSpaceDialog"
contentId="mx_LeaveSpaceDialog"
onFinished={() => onFinished(false)}
fixedWidth={false}
>
<div className="mx_Dialog_content" id="mx_LeaveSpaceDialog">
<p>
{_t(
"space|leave_dialog_description",
{},
{
spaceName: () => <strong>{space.name}</strong>,
},
)}
&nbsp;
{rejoinWarning}
{rejoinWarning && <>&nbsp;</>}
{spaceChildren.length > 0 && _t("space|leave_dialog_option_intro")}
</p>
{spaceChildren.length > 0 && (
<SpaceChildrenPicker
space={space}
spaceChildren={spaceChildren}
selected={selectedRooms}
onChange={setRoomsToLeave}
noneLabel={_t("space|leave_dialog_option_none")}
allLabel={_t("space|leave_dialog_option_all")}
specificLabel={_t("space|leave_dialog_option_specific")}
/>
)}
{onlyAdminWarning && <div className="mx_LeaveSpaceDialog_section_warning">{onlyAdminWarning}</div>}
</div>
<DialogButtons
primaryButton={_t("space|leave_dialog_action")}
primaryButtonClass="danger"
onPrimaryButtonClick={() => onFinished(true, roomsToLeave)}
hasCancel={true}
onCancel={() => onFinished(false)}
/>
</BaseDialog>
);
};
export default LeaveSpaceDialog;

View file

@ -0,0 +1,265 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020-2022 The Matrix.org Foundation C.I.C.
Copyright 2018, 2019 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.
*/
import React from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import type CreateKeyBackupDialog from "../../../async-components/views/dialogs/security/CreateKeyBackupDialog";
import type ExportE2eKeysDialog from "../../../async-components/views/dialogs/security/ExportE2eKeysDialog";
import Modal from "../../../Modal";
import dis from "../../../dispatcher/dispatcher";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import RestoreKeyBackupDialog from "./security/RestoreKeyBackupDialog";
import QuestionDialog from "./QuestionDialog";
import BaseDialog from "./BaseDialog";
import Spinner from "../elements/Spinner";
import DialogButtons from "../elements/DialogButtons";
interface IProps {
onFinished: (success: boolean) => void;
}
enum BackupStatus {
/** we're trying to figure out if there is an active backup */
LOADING,
/** crypto is disabled in this client (so no need to back up) */
NO_CRYPTO,
/** Key backup is active and working */
BACKUP_ACTIVE,
/** there is a backup on the server but we are not backing up to it */
SERVER_BACKUP_BUT_DISABLED,
/** backup is not set up locally and there is no backup on the server */
NO_BACKUP,
/** there was an error fetching the state */
ERROR,
}
interface IState {
backupStatus: BackupStatus;
}
/**
* Checks if the `LogoutDialog` should be shown instead of the simple logout flow.
* The `LogoutDialog` will check the crypto recovery status of the account and
* help the user setup recovery properly if needed.
*/
export async function shouldShowLogoutDialog(cli: MatrixClient): Promise<boolean> {
const crypto = cli?.getCrypto();
if (!crypto) return false;
// If any room is encrypted, we need to show the advanced logout flow
const allRooms = cli!.getRooms();
for (const room of allRooms) {
const isE2e = await crypto.isEncryptionEnabledInRoom(room.roomId);
if (isE2e) return true;
}
return false;
}
export default class LogoutDialog extends React.Component<IProps, IState> {
public static defaultProps = {
onFinished: function () {},
};
public constructor(props: IProps) {
super(props);
this.state = {
backupStatus: BackupStatus.LOADING,
};
// we can't call setState() immediately, so wait a beat
window.setTimeout(() => this.startLoadBackupStatus(), 0);
}
/** kick off the asynchronous calls to populate `state.backupStatus` in the background */
private startLoadBackupStatus(): void {
this.loadBackupStatus().catch((e) => {
logger.log("Unable to fetch key backup status", e);
this.setState({
backupStatus: BackupStatus.ERROR,
});
});
}
private async loadBackupStatus(): Promise<void> {
const client = MatrixClientPeg.safeGet();
const crypto = client.getCrypto();
if (!crypto) {
this.setState({ backupStatus: BackupStatus.NO_CRYPTO });
return;
}
if ((await crypto.getActiveSessionBackupVersion()) !== null) {
this.setState({ backupStatus: BackupStatus.BACKUP_ACTIVE });
return;
}
// backup is not active. see if there is a backup version on the server we ought to back up to.
const backupInfo = await client.getKeyBackupVersion();
this.setState({ backupStatus: backupInfo ? BackupStatus.SERVER_BACKUP_BUT_DISABLED : BackupStatus.NO_BACKUP });
}
private onExportE2eKeysClicked = (): void => {
Modal.createDialogAsync(
import("../../../async-components/views/dialogs/security/ExportE2eKeysDialog") as unknown as Promise<
typeof ExportE2eKeysDialog
>,
{
matrixClient: MatrixClientPeg.safeGet(),
},
);
};
private onFinished = (confirmed?: boolean): void => {
if (confirmed) {
dis.dispatch({ action: "logout" });
}
// close dialog
this.props.onFinished(!!confirmed);
};
private onSetRecoveryMethodClick = (): void => {
if (this.state.backupStatus === BackupStatus.SERVER_BACKUP_BUT_DISABLED) {
// A key backup exists for this account, but the creating device is not
// verified, so restore the backup which will give us the keys from it and
// allow us to trust it (ie. upload keys to it)
Modal.createDialog(
RestoreKeyBackupDialog,
undefined,
undefined,
/* priority = */ false,
/* static = */ true,
);
} else {
Modal.createDialogAsync(
import("../../../async-components/views/dialogs/security/CreateKeyBackupDialog") as unknown as Promise<
typeof CreateKeyBackupDialog
>,
undefined,
undefined,
/* priority = */ false,
/* static = */ true,
);
}
// close dialog
this.props.onFinished(true);
};
private onLogoutConfirm = (): void => {
dis.dispatch({ action: "logout" });
// close dialog
this.props.onFinished(true);
};
/**
* Show a dialog prompting the user to set up key backup.
*
* Either there is no backup at all ({@link BackupStatus.NO_BACKUP}), there is a backup on the server but
* we are not connected to it ({@link BackupStatus.SERVER_BACKUP_BUT_DISABLED}), or we were unable to pull the
* backup data ({@link BackupStatus.ERROR}). In all three cases, we should prompt the user to set up key backup.
*/
private renderSetupBackupDialog(): React.ReactNode {
const description = (
<div>
<p>{_t("auth|logout_dialog|setup_secure_backup_description_1")}</p>
<p>{_t("auth|logout_dialog|setup_secure_backup_description_2")}</p>
<p>{_t("encryption|setup_secure_backup|explainer")}</p>
</div>
);
let setupButtonCaption;
if (this.state.backupStatus === BackupStatus.SERVER_BACKUP_BUT_DISABLED) {
setupButtonCaption = _t("settings|security|key_backup_connect");
} else {
// if there's an error fetching the backup info, we'll just assume there's
// no backup for the purpose of the button caption
setupButtonCaption = _t("auth|logout_dialog|use_key_backup");
}
const dialogContent = (
<div>
<div className="mx_Dialog_content" id="mx_Dialog_content">
{description}
</div>
<DialogButtons
primaryButton={setupButtonCaption}
hasCancel={false}
onPrimaryButtonClick={this.onSetRecoveryMethodClick}
focus={true}
>
<button onClick={this.onLogoutConfirm}>{_t("auth|logout_dialog|skip_key_backup")}</button>
</DialogButtons>
<details>
<summary className="mx_LogoutDialog_ExportKeyAdvanced">{_t("common|advanced")}</summary>
<p>
<button onClick={this.onExportE2eKeysClicked}>{_t("auth|logout_dialog|megolm_export")}</button>
</p>
</details>
</div>
);
// Not quite a standard question dialog as the primary button cancels
// the action and does something else instead, whilst non-default button
// confirms the action.
return (
<BaseDialog
title={_t("auth|logout_dialog|setup_key_backup_title")}
contentId="mx_Dialog_content"
hasCancel={true}
onFinished={this.onFinished}
>
{dialogContent}
</BaseDialog>
);
}
public render(): React.ReactNode {
switch (this.state.backupStatus) {
case BackupStatus.LOADING:
// while we're deciding if we have backups, show a spinner
return (
<BaseDialog
title={_t("action|sign_out")}
contentId="mx_Dialog_content"
hasCancel={true}
onFinished={this.onFinished}
>
<Spinner />
</BaseDialog>
);
case BackupStatus.NO_CRYPTO:
case BackupStatus.BACKUP_ACTIVE:
return (
<QuestionDialog
hasCancelButton={true}
title={_t("action|sign_out")}
description={_t("auth|logout_dialog|description")}
button={_t("action|sign_out")}
onFinished={this.onFinished}
/>
);
case BackupStatus.NO_BACKUP:
case BackupStatus.SERVER_BACKUP_BUT_DISABLED:
case BackupStatus.ERROR:
return this.renderSetupBackupDialog();
}
}
}

View file

@ -0,0 +1,241 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useMemo, useState } from "react";
import { Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { _t } from "../../../languageHandler";
import BaseDialog from "./BaseDialog";
import SearchBox from "../../structures/SearchBox";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import RoomAvatar from "../avatars/RoomAvatar";
import AccessibleButton from "../elements/AccessibleButton";
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
import StyledCheckbox from "../elements/StyledCheckbox";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { filterBoolean } from "../../../utils/arrays";
interface IProps {
room: Room;
selected?: string[];
onFinished(rooms?: string[]): void;
}
const Entry: React.FC<{
room: Room;
checked: boolean;
onChange(value: boolean): void;
}> = ({ room, checked, onChange }) => {
const localRoom = room instanceof Room;
let description;
if (localRoom) {
description = _t("common|n_members", { count: room.getJoinedMemberCount() });
const numChildRooms = SpaceStore.instance.getChildRooms(room.roomId).length;
if (numChildRooms > 0) {
description += " · " + _t("common|n_rooms", { count: numChildRooms });
}
}
return (
<label className="mx_ManageRestrictedJoinRuleDialog_entry">
<div>
<div>
{localRoom ? <RoomAvatar room={room} size="20px" /> : <RoomAvatar oobData={room} size="20px" />}
<span className="mx_ManageRestrictedJoinRuleDialog_entry_name">{room.name}</span>
</div>
{description && (
<div className="mx_ManageRestrictedJoinRuleDialog_entry_description">{description}</div>
)}
</div>
<StyledCheckbox
onChange={onChange ? (e) => onChange(e.target.checked) : undefined}
checked={checked}
disabled={!onChange}
/>
</label>
);
};
const addAllParents = (set: Set<Room>, room: Room): void => {
const cli = room.client;
const parents = Array.from(SpaceStore.instance.getKnownParents(room.roomId)).map((parentId) =>
cli.getRoom(parentId),
);
parents.forEach((parent) => {
if (!parent || set.has(parent)) return;
set.add(parent);
addAllParents(set, parent);
});
};
const ManageRestrictedJoinRuleDialog: React.FC<IProps> = ({ room, selected = [], onFinished }) => {
const cli = room.client;
const [newSelected, setNewSelected] = useState(new Set<string>(selected));
const [query, setQuery] = useState("");
const lcQuery = query.toLowerCase().trim();
const [spacesContainingRoom, otherJoinedSpaces, otherEntries] = useMemo(() => {
const parents = new Set<Room>();
addAllParents(parents, room);
return [
Array.from(parents),
SpaceStore.instance.spacePanelSpaces.filter((s) => !parents.has(s)),
filterBoolean(
selected.map((roomId) => {
const room = cli.getRoom(roomId);
if (!room) {
return { roomId, name: roomId } as Room;
}
if (room.getMyMembership() !== KnownMembership.Join || !room.isSpaceRoom()) {
return room;
}
}),
),
];
}, [cli, selected, room]);
const [filteredSpacesContainingRoom, filteredOtherJoinedSpaces, filteredOtherEntries] = useMemo(
() => [
spacesContainingRoom.filter((r) => r.name.toLowerCase().includes(lcQuery)),
otherJoinedSpaces.filter((r) => r.name.toLowerCase().includes(lcQuery)),
otherEntries.filter((r) => r.name.toLowerCase().includes(lcQuery)),
],
[spacesContainingRoom, otherJoinedSpaces, otherEntries, lcQuery],
);
const onChange = (checked: boolean, room: Room): void => {
if (checked) {
newSelected.add(room.roomId);
} else {
newSelected.delete(room.roomId);
}
setNewSelected(new Set(newSelected));
};
let inviteOnlyWarning;
if (newSelected.size < 1) {
inviteOnlyWarning = (
<div className="mx_ManageRestrictedJoinRuleDialog_section_info">
{_t("room_settings|security|join_rule_restricted_dialog_empty_warning")}
</div>
);
}
const totalResults =
filteredSpacesContainingRoom.length + filteredOtherJoinedSpaces.length + filteredOtherEntries.length;
return (
<BaseDialog
title={_t("room_settings|security|join_rule_restricted_dialog_title")}
className="mx_ManageRestrictedJoinRuleDialog"
onFinished={onFinished}
fixedWidth={false}
>
<p>
{_t(
"room_settings|security|join_rule_restricted_dialog_description",
{},
{
RoomName: () => <strong>{room.name}</strong>,
},
)}
</p>
<MatrixClientContext.Provider value={cli}>
<SearchBox
className="mx_textinput_icon mx_textinput_search"
placeholder={_t("room_settings|security|join_rule_restricted_dialog_filter_placeholder")}
onSearch={setQuery}
autoFocus={true}
/>
<AutoHideScrollbar className="mx_ManageRestrictedJoinRuleDialog_content">
{filteredSpacesContainingRoom.length > 0 ? (
<div className="mx_ManageRestrictedJoinRuleDialog_section">
<h3>
{room.isSpaceRoom()
? _t("room_settings|security|join_rule_restricted_dialog_heading_space")
: _t("room_settings|security|join_rule_restricted_dialog_heading_room")}
</h3>
{filteredSpacesContainingRoom.map((space) => {
return (
<Entry
key={space.roomId}
room={space}
checked={newSelected.has(space.roomId)}
onChange={(checked: boolean) => {
onChange(checked, space);
}}
/>
);
})}
</div>
) : undefined}
{filteredOtherEntries.length > 0 ? (
<div className="mx_ManageRestrictedJoinRuleDialog_section">
<h3>{_t("room_settings|security|join_rule_restricted_dialog_heading_other")}</h3>
<div className="mx_ManageRestrictedJoinRuleDialog_section_info">
<div>{_t("room_settings|security|join_rule_restricted_dialog_heading_unknown")}</div>
</div>
{filteredOtherEntries.map((space) => {
return (
<Entry
key={space.roomId}
room={space}
checked={newSelected.has(space.roomId)}
onChange={(checked: boolean) => {
onChange(checked, space);
}}
/>
);
})}
</div>
) : null}
{filteredOtherJoinedSpaces.length > 0 ? (
<div className="mx_ManageRestrictedJoinRuleDialog_section">
<h3>{_t("room_settings|security|join_rule_restricted_dialog_heading_known")}</h3>
{filteredOtherJoinedSpaces.map((space) => {
return (
<Entry
key={space.roomId}
room={space}
checked={newSelected.has(space.roomId)}
onChange={(checked: boolean) => {
onChange(checked, space);
}}
/>
);
})}
</div>
) : null}
{totalResults < 1 ? (
<span className="mx_ManageRestrictedJoinRuleDialog_noResults">{_t("common|no_results")}</span>
) : undefined}
</AutoHideScrollbar>
<div className="mx_ManageRestrictedJoinRuleDialog_footer">
{inviteOnlyWarning}
<div className="mx_ManageRestrictedJoinRuleDialog_footer_buttons">
<AccessibleButton kind="primary_outline" onClick={() => onFinished()}>
{_t("action|cancel")}
</AccessibleButton>
<AccessibleButton kind="primary" onClick={() => onFinished(Array.from(newSelected))}>
{_t("action|confirm")}
</AccessibleButton>
</div>
</div>
</MatrixClientContext.Provider>
</BaseDialog>
);
};
export default ManageRestrictedJoinRuleDialog;

View file

@ -0,0 +1,90 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2017 Vector Creations Ltd
Copyright 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useCallback } from "react";
import { Device } from "matrix-js-sdk/src/matrix";
import * as FormattingUtils from "../../../utils/FormattingUtils";
import { _t } from "../../../languageHandler";
import QuestionDialog from "./QuestionDialog";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
interface IManualDeviceKeyVerificationDialogProps {
userId: string;
device: Device;
onFinished(confirm?: boolean): void;
}
export function ManualDeviceKeyVerificationDialog({
userId,
device,
onFinished,
}: IManualDeviceKeyVerificationDialogProps): JSX.Element {
const mxClient = MatrixClientPeg.safeGet();
const onLegacyFinished = useCallback(
(confirm: boolean) => {
if (confirm) {
mxClient.setDeviceVerified(userId, device.deviceId, true);
}
onFinished(confirm);
},
[mxClient, userId, device, onFinished],
);
let text;
if (mxClient?.getUserId() === userId) {
text = _t("encryption|verification|manual_device_verification_self_text");
} else {
text = _t("encryption|verification|manual_device_verification_user_text");
}
const fingerprint = device.getFingerprint();
const key = fingerprint && FormattingUtils.formatCryptoKey(fingerprint);
const body = (
<div>
<p>{text}</p>
<div className="mx_DeviceVerifyDialog_cryptoSection">
<ul>
<li>
<label>{_t("encryption|verification|manual_device_verification_device_name_label")}:</label>{" "}
<span>{device.displayName}</span>
</li>
<li>
<label>{_t("encryption|verification|manual_device_verification_device_id_label")}:</label>{" "}
<span>
<code>{device.deviceId}</code>
</span>
</li>
<li>
<label>{_t("encryption|verification|manual_device_verification_device_key_label")}:</label>{" "}
<span>
<code>
<strong>{key}</strong>
</code>
</span>
</li>
</ul>
</div>
<p>{_t("encryption|verification|manual_device_verification_footer")}</p>
</div>
);
return (
<QuestionDialog
title={_t("settings|sessions|verify_session")}
description={body}
button={_t("settings|sessions|verify_session")}
onFinished={onLegacyFinished}
/>
);
}

View file

@ -0,0 +1,187 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { MatrixEvent, EventType, RelationType, MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix";
import { defer } from "matrix-js-sdk/src/utils";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { _t } from "../../../languageHandler";
import { wantsDateSeparator } from "../../../DateUtils";
import SettingsStore from "../../../settings/SettingsStore";
import BaseDialog from "./BaseDialog";
import ScrollPanel from "../../structures/ScrollPanel";
import Spinner from "../elements/Spinner";
import EditHistoryMessage from "../messages/EditHistoryMessage";
import DateSeparator from "../messages/DateSeparator";
interface IProps {
mxEvent: MatrixEvent;
onFinished(): void;
}
interface IState {
originalEvent: MatrixEvent | null;
error: MatrixError | null;
events: MatrixEvent[];
nextBatch: string | null;
isLoading: boolean;
isTwelveHour: boolean;
}
export default class MessageEditHistoryDialog extends React.PureComponent<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
originalEvent: null,
error: null,
events: [],
nextBatch: null,
isLoading: true,
isTwelveHour: SettingsStore.getValue("showTwelveHourTimestamps"),
};
}
private loadMoreEdits = async (backwards?: boolean): Promise<boolean> => {
if (backwards || (!this.state.nextBatch && !this.state.isLoading)) {
// bail out on backwards as we only paginate in one direction
return false;
}
const opts = { from: this.state.nextBatch ?? undefined };
const roomId = this.props.mxEvent.getRoomId()!;
const eventId = this.props.mxEvent.getId()!;
const client = MatrixClientPeg.safeGet();
const { resolve, reject, promise } = defer<boolean>();
let result: Awaited<ReturnType<MatrixClient["relations"]>>;
try {
result = await client.relations(roomId, eventId, RelationType.Replace, EventType.RoomMessage, opts);
} catch (error) {
// log if the server returned an error
if (error instanceof MatrixError && error.errcode) {
logger.error("fetching /relations failed with error", error);
}
this.setState({ error: error as MatrixError }, () => reject(error));
return promise;
}
const newEvents = result.events;
this.locallyRedactEventsIfNeeded(newEvents);
this.setState(
{
originalEvent: this.state.originalEvent ?? result.originalEvent ?? null,
events: this.state.events.concat(newEvents),
nextBatch: result.nextBatch ?? null,
isLoading: false,
},
() => {
const hasMoreResults = !!this.state.nextBatch;
resolve(hasMoreResults);
},
);
return promise;
};
private locallyRedactEventsIfNeeded(newEvents: MatrixEvent[]): void {
const roomId = this.props.mxEvent.getRoomId();
const client = MatrixClientPeg.safeGet();
const room = client.getRoom(roomId);
if (!room) return;
const pendingEvents = room.getPendingEvents();
for (const e of newEvents) {
const pendingRedaction = pendingEvents.find((pe) => {
return pe.getType() === EventType.RoomRedaction && pe.getAssociatedId() === e.getId();
});
if (pendingRedaction) {
e.markLocallyRedacted(pendingRedaction);
}
}
}
public componentDidMount(): void {
this.loadMoreEdits();
}
private renderEdits(): JSX.Element[] {
const nodes: JSX.Element[] = [];
let lastEvent: MatrixEvent;
let allEvents = this.state.events;
// append original event when we've done last pagination
if (this.state.originalEvent && !this.state.nextBatch) {
allEvents = allEvents.concat(this.state.originalEvent);
}
const baseEventId = this.props.mxEvent.getId();
allEvents.forEach((e, i) => {
if (!lastEvent || wantsDateSeparator(lastEvent.getDate() || undefined, e.getDate() || undefined)) {
nodes.push(
<li key={e.getTs() + "~"}>
<DateSeparator roomId={e.getRoomId()!} ts={e.getTs()} />
</li>,
);
}
const isBaseEvent = e.getId() === baseEventId;
nodes.push(
<EditHistoryMessage
key={e.getId()}
previousEdit={!isBaseEvent ? allEvents[i + 1] : undefined}
isBaseEvent={isBaseEvent}
mxEvent={e}
isTwelveHour={this.state.isTwelveHour}
/>,
);
lastEvent = e;
});
return nodes;
}
public render(): React.ReactNode {
let content;
if (this.state.error) {
const { error } = this.state;
if (error.errcode === "M_UNRECOGNIZED") {
content = <p className="mx_MessageEditHistoryDialog_error">{_t("error|edit_history_unsupported")}</p>;
} else if (error.errcode) {
// some kind of error from the homeserver
content = <p className="mx_MessageEditHistoryDialog_error">{_t("error|something_went_wrong")}</p>;
} else {
content = (
<p className="mx_MessageEditHistoryDialog_error">
{_t("cannot_reach_homeserver")}
<br />
{_t("cannot_reach_homeserver_detail")}
</p>
);
}
} else if (this.state.isLoading) {
content = <Spinner />;
} else {
content = (
<ScrollPanel
className="mx_MessageEditHistoryDialog_scrollPanel"
onFillRequest={this.loadMoreEdits}
stickyBottom={false}
startAtBottom={false}
>
<ul className="mx_MessageEditHistoryDialog_edits">{this.renderEdits()}</ul>
</ScrollPanel>
);
}
return (
<BaseDialog
className="mx_MessageEditHistoryDialog"
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("message_edit_dialog_title")}
>
{content}
</BaseDialog>
);
}
}

View file

@ -0,0 +1,211 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import * as React from "react";
import {
ClientWidgetApi,
IModalWidgetCloseRequest,
IModalWidgetOpenRequestData,
IModalWidgetReturnData,
ISetModalButtonEnabledActionRequest,
IWidgetApiAcknowledgeResponseData,
IWidgetApiErrorResponseData,
BuiltInModalButtonID,
ModalButtonID,
ModalButtonKind,
Widget,
WidgetApiFromWidgetAction,
WidgetKind,
} from "matrix-widget-api";
import BaseDialog from "./BaseDialog";
import { _t, getUserLanguage } from "../../../languageHandler";
import AccessibleButton, { AccessibleButtonKind } from "../elements/AccessibleButton";
import { StopGapWidgetDriver } from "../../../stores/widgets/StopGapWidgetDriver";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { OwnProfileStore } from "../../../stores/OwnProfileStore";
import { arrayFastClone } from "../../../utils/arrays";
import { ElementWidget } from "../../../stores/widgets/StopGapWidget";
import { ELEMENT_CLIENT_ID } from "../../../identifiers";
import SettingsStore from "../../../settings/SettingsStore";
interface IProps {
widgetDefinition: IModalWidgetOpenRequestData;
widgetRoomId?: string;
sourceWidgetId: string;
onFinished(success: true, data: IModalWidgetReturnData): void;
onFinished(success?: false, data?: void): void;
}
interface IState {
messaging?: ClientWidgetApi;
disabledButtonIds: ModalButtonID[];
}
const MAX_BUTTONS = 3;
export default class ModalWidgetDialog extends React.PureComponent<IProps, IState> {
private readonly widget: Widget;
private readonly possibleButtons: ModalButtonID[];
private appFrame: React.RefObject<HTMLIFrameElement> = React.createRef();
public state: IState = {
disabledButtonIds: (this.props.widgetDefinition.buttons || []).filter((b) => b.disabled).map((b) => b.id),
};
public constructor(props: IProps) {
super(props);
this.widget = new ElementWidget({
...this.props.widgetDefinition,
creatorUserId: MatrixClientPeg.safeGet().getSafeUserId(),
id: `modal_${this.props.sourceWidgetId}`,
});
this.possibleButtons = (this.props.widgetDefinition.buttons || []).map((b) => b.id);
}
public componentDidMount(): void {
const driver = new StopGapWidgetDriver([], this.widget, WidgetKind.Modal, false);
const messaging = new ClientWidgetApi(this.widget, this.appFrame.current!, driver);
this.setState({ messaging });
}
public componentWillUnmount(): void {
if (!this.state.messaging) return;
this.state.messaging.off("ready", this.onReady);
this.state.messaging.off(`action:${WidgetApiFromWidgetAction.CloseModalWidget}`, this.onWidgetClose);
this.state.messaging.stop();
}
private onReady = (): void => {
this.state.messaging?.sendWidgetConfig(this.props.widgetDefinition);
};
private onLoad = (): void => {
if (!this.state.messaging) return;
this.state.messaging.once("ready", this.onReady);
this.state.messaging.on(`action:${WidgetApiFromWidgetAction.CloseModalWidget}`, this.onWidgetClose);
this.state.messaging.on(`action:${WidgetApiFromWidgetAction.SetModalButtonEnabled}`, this.onButtonEnableToggle);
};
private onWidgetClose = (ev: CustomEvent<IModalWidgetCloseRequest>): void => {
this.props.onFinished(true, ev.detail.data);
};
private onButtonEnableToggle = (ev: CustomEvent<ISetModalButtonEnabledActionRequest>): void => {
ev.preventDefault();
const isClose = ev.detail.data.button === BuiltInModalButtonID.Close;
if (isClose || !this.possibleButtons.includes(ev.detail.data.button)) {
return this.state.messaging?.transport.reply(ev.detail, {
error: { message: "Invalid button" },
} as IWidgetApiErrorResponseData);
}
let buttonIds: ModalButtonID[];
if (ev.detail.data.enabled) {
buttonIds = arrayFastClone(this.state.disabledButtonIds).filter((i) => i !== ev.detail.data.button);
} else {
// use a set to swap the operation to avoid memory leaky arrays.
const tempSet = new Set(this.state.disabledButtonIds);
tempSet.add(ev.detail.data.button);
buttonIds = Array.from(tempSet);
}
this.setState({ disabledButtonIds: buttonIds });
this.state.messaging?.transport.reply(ev.detail, {} as IWidgetApiAcknowledgeResponseData);
};
public render(): React.ReactNode {
const templated = this.widget.getCompleteUrl({
widgetRoomId: this.props.widgetRoomId,
currentUserId: MatrixClientPeg.safeGet().getSafeUserId(),
userDisplayName: OwnProfileStore.instance.displayName ?? undefined,
userHttpAvatarUrl: OwnProfileStore.instance.getHttpAvatarUrl() ?? undefined,
clientId: ELEMENT_CLIENT_ID,
clientTheme: SettingsStore.getValue("theme"),
clientLanguage: getUserLanguage(),
baseUrl: MatrixClientPeg.safeGet().baseUrl,
});
const parsed = new URL(templated);
// Add in some legacy support sprinkles (for non-popout widgets)
// TODO: Replace these with proper widget params
// See https://github.com/matrix-org/matrix-doc/pull/1958/files#r405714833
parsed.searchParams.set("widgetId", this.widget.id);
parsed.searchParams.set("parentUrl", window.location.href.split("#", 2)[0]);
// Replace the encoded dollar signs back to dollar signs. They have no special meaning
// in HTTP, but URL parsers encode them anyways.
const widgetUrl = parsed.toString().replace(/%24/g, "$");
let buttons;
if (this.props.widgetDefinition.buttons) {
// show first button rightmost for a more natural specification
buttons = this.props.widgetDefinition.buttons
.slice(0, MAX_BUTTONS)
.reverse()
.map((def) => {
let kind: AccessibleButtonKind = "secondary";
switch (def.kind) {
case ModalButtonKind.Primary:
kind = "primary";
break;
case ModalButtonKind.Secondary:
kind = "primary_outline";
break;
case ModalButtonKind.Danger:
kind = "danger";
break;
}
const onClick = (): void => {
this.state.messaging?.notifyModalWidgetButtonClicked(def.id);
};
const isDisabled = this.state.disabledButtonIds.includes(def.id);
return (
<AccessibleButton key={def.id} kind={kind} onClick={onClick} disabled={isDisabled}>
{def.label}
</AccessibleButton>
);
});
}
return (
<BaseDialog
title={this.props.widgetDefinition.name || _t("widget|modal_title_default")}
className="mx_ModalWidgetDialog"
contentId="mx_Dialog_content"
onFinished={this.props.onFinished}
>
<div className="mx_ModalWidgetDialog_warning">
<img
src={require("../../../../res/img/element-icons/warning-badge.svg").default}
height="16"
width="16"
alt=""
/>
{_t("widget|modal_data_warning", {
widgetDomain: parsed.hostname,
})}
</div>
<div>
<iframe
title={this.widget.name ?? undefined}
ref={this.appFrame}
sandbox="allow-forms allow-scripts allow-same-origin"
src={widgetUrl}
onLoad={this.onLoad}
/>
</div>
<div className="mx_ModalWidgetDialog_buttons">{buttons}</div>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,79 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { createRef } from "react";
import { DialogContent, DialogProps } from "@matrix-org/react-sdk-module-api/lib/components/DialogContent";
import { logger } from "matrix-js-sdk/src/logger";
import { ModuleApi } from "@matrix-org/react-sdk-module-api/lib/ModuleApi";
import { ModuleUiDialogOptions } from "@matrix-org/react-sdk-module-api/lib/types/ModuleUiDialogOptions";
import ScrollableBaseModal, { IScrollableBaseState } from "./ScrollableBaseModal";
import { _t } from "../../../languageHandler";
interface IProps<P extends DialogProps, C extends DialogContent<P>> {
contentFactory: (props: P, ref: React.RefObject<C>) => React.ReactNode;
additionalContentProps: Omit<P, keyof DialogProps> | undefined;
initialOptions: ModuleUiDialogOptions;
moduleApi: ModuleApi;
onFinished(ok?: boolean, model?: Awaited<ReturnType<DialogContent<P & DialogProps>["trySubmit"]>>): void;
}
interface IState extends IScrollableBaseState {
// nothing special
}
export class ModuleUiDialog<P extends DialogProps, C extends DialogContent<P>> extends ScrollableBaseModal<
IProps<P, C>,
IState
> {
private contentRef = createRef<C>();
public constructor(props: IProps<P, C>) {
super(props);
this.state = {
title: this.props.initialOptions.title,
actionLabel: this.props.initialOptions.actionLabel ?? _t("action|ok"),
cancelLabel: this.props.initialOptions.cancelLabel,
canSubmit: this.props.initialOptions.canSubmit ?? true,
};
}
protected async submit(): Promise<void> {
try {
const model = await this.contentRef.current!.trySubmit();
this.props.onFinished(true, model);
} catch (e) {
logger.error("Error during submission of module dialog:", e);
}
}
protected cancel(): void {
this.props.onFinished(false);
}
private setOptions(options: ModuleUiDialogOptions): void {
this.setState((state) => ({ ...state, ...options }));
}
protected renderContent(): React.ReactNode {
const dialogProps: DialogProps = {
moduleApi: this.props.moduleApi,
setOptions: this.setOptions.bind(this),
cancel: this.cancel.bind(this),
};
// Typescript isn't very happy understanding that `contentProps` satisfies `P`
const contentProps: P = {
...this.props.additionalContentProps,
...dialogProps,
} as unknown as P;
return <div className="mx_ModuleUiDialog">{this.props.contentFactory(contentProps, this.contentRef)}</div>;
}
}

View file

@ -0,0 +1,40 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { PollHistory } from "../polls/pollHistory/PollHistory";
import BaseDialog from "./BaseDialog";
type PollHistoryDialogProps = {
room: Room;
matrixClient: MatrixClient;
permalinkCreator: RoomPermalinkCreator;
onFinished(): void;
};
export const PollHistoryDialog: React.FC<PollHistoryDialogProps> = ({
room,
matrixClient,
permalinkCreator,
onFinished,
}) => {
// @TODO hide dialog title somehow
return (
<BaseDialog onFinished={onFinished}>
<PollHistory
room={room}
matrixClient={matrixClient}
permalinkCreator={permalinkCreator}
onFinished={onFinished}
/>
</BaseDialog>
);
};

View file

@ -0,0 +1,85 @@
/*
Copyright 2017-2024 New Vector Ltd.
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import classNames from "classnames";
import { _t } from "../../../languageHandler";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
export interface IQuestionDialogProps {
title?: string;
description?: React.ReactNode;
extraButtons?: React.ReactNode;
button?: string;
buttonDisabled?: boolean;
danger?: boolean;
focus?: boolean;
headerImage?: string;
quitOnly?: boolean; // quitOnly doesn't show the cancel button just the quit [x].
fixedWidth?: boolean;
className?: string;
hasCancelButton?: boolean;
cancelButton?: React.ReactNode;
onFinished(ok?: boolean): void;
}
export default class QuestionDialog extends React.Component<IQuestionDialogProps> {
public static defaultProps: Partial<IQuestionDialogProps> = {
title: "",
description: "",
extraButtons: null,
focus: true,
hasCancelButton: true,
danger: false,
quitOnly: false,
};
private onOk = (): void => {
this.props.onFinished(true);
};
private onCancel = (): void => {
this.props.onFinished(false);
};
public render(): React.ReactNode {
let primaryButtonClass = "";
if (this.props.danger) {
primaryButtonClass = "danger";
}
return (
<BaseDialog
className={classNames("mx_QuestionDialog", this.props.className)}
onFinished={this.props.onFinished}
title={this.props.title}
contentId="mx_Dialog_content"
headerImage={this.props.headerImage}
hasCancel={this.props.hasCancelButton}
fixedWidth={this.props.fixedWidth}
>
<div className="mx_Dialog_content" id="mx_Dialog_content">
{this.props.description}
</div>
<DialogButtons
primaryButton={this.props.button || _t("action|ok")}
primaryButtonClass={primaryButtonClass}
primaryDisabled={this.props.buttonDisabled}
cancelButton={this.props.cancelButton}
hasCancel={this.props.hasCancelButton && !this.props.quitOnly}
onPrimaryButtonClick={this.onOk}
focus={this.props.focus}
onCancel={this.onCancel}
>
{this.props.extraButtons}
</DialogButtons>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,79 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import * as React from "react";
import { SyntheticEvent, useRef, useState } from "react";
import { _t, _td } from "../../../languageHandler";
import Field from "../elements/Field";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import EmailField from "../auth/EmailField";
interface IProps {
onFinished(continued: false, email?: undefined): void;
onFinished(continued: true, email: string): void;
}
const RegistrationEmailPromptDialog: React.FC<IProps> = ({ onFinished }) => {
const [email, setEmail] = useState("");
const fieldRef = useRef<Field>(null);
const onSubmit = async (e: SyntheticEvent): Promise<void> => {
e.preventDefault();
if (!fieldRef.current) return;
if (email) {
const valid = await fieldRef.current.validate({});
if (!valid) {
fieldRef.current.focus();
fieldRef.current.validate({ focused: true });
return;
}
}
onFinished(true, email);
};
return (
<BaseDialog
title={_t("auth|registration|continue_without_email_title")}
className="mx_RegistrationEmailPromptDialog"
contentId="mx_RegistrationEmailPromptDialog"
onFinished={() => onFinished(false)}
fixedWidth={false}
>
<div className="mx_Dialog_content" id="mx_RegistrationEmailPromptDialog">
<p>
{_t(
"auth|registration|continue_without_email_description",
{},
{
b: (sub) => <strong>{sub}</strong>,
},
)}
</p>
<form onSubmit={onSubmit}>
<EmailField
fieldRef={fieldRef}
autoFocus={true}
label={_td("auth|registration|continue_without_email_field_label")}
value={email}
onChange={(ev) => {
const target = ev.target as HTMLInputElement;
setEmail(target.value);
}}
/>
</form>
</div>
<DialogButtons primaryButton={_t("action|continue")} onPrimaryButtonClick={onSubmit} hasCancel={false} />
</BaseDialog>
);
};
export default RegistrationEmailPromptDialog;

View file

@ -0,0 +1,470 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ChangeEvent } from "react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { _t, UserFriendlyError } from "../../../languageHandler";
import { ensureDMExists } from "../../../createRoom";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import SdkConfig from "../../../SdkConfig";
import Markdown from "../../../Markdown";
import SettingsStore from "../../../settings/SettingsStore";
import StyledRadioButton from "../elements/StyledRadioButton";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import Field from "../elements/Field";
import Spinner from "../elements/Spinner";
import LabelledCheckbox from "../elements/LabelledCheckbox";
declare module "matrix-js-sdk/src/types" {
interface TimelineEvents {
[ABUSE_EVENT_TYPE]: AbuseEventContent;
}
}
interface IProps {
mxEvent: MatrixEvent;
onFinished(report?: boolean): void;
}
interface IState {
// A free-form text describing the abuse.
reason: string;
busy: boolean;
err?: string;
// If we know it, the nature of the abuse, as specified by MSC3215.
nature?: ExtendedNature;
ignoreUserToo: boolean; // if true, user will be ignored/blocked on submit
}
const MODERATED_BY_STATE_EVENT_TYPE = [
"org.matrix.msc3215.room.moderation.moderated_by",
/**
* Unprefixed state event. Not ready for prime time.
*
* "m.room.moderation.moderated_by"
*/
];
export const ABUSE_EVENT_TYPE = "org.matrix.msc3215.abuse.report";
interface AbuseEventContent {
event_id: string;
room_id: string;
moderated_by_id: string;
nature?: ExtendedNature;
reporter: string;
comment: string;
}
// Standard abuse natures.
enum Nature {
Disagreement = "org.matrix.msc3215.abuse.nature.disagreement",
Toxic = "org.matrix.msc3215.abuse.nature.toxic",
Illegal = "org.matrix.msc3215.abuse.nature.illegal",
Spam = "org.matrix.msc3215.abuse.nature.spam",
Other = "org.matrix.msc3215.abuse.nature.other",
}
enum NonStandardValue {
// Non-standard abuse nature.
// It should never leave the client - we use it to fallback to
// server-wide abuse reporting.
Admin = "non-standard.abuse.nature.admin",
}
type ExtendedNature = Nature | NonStandardValue;
type Moderation = {
// The id of the moderation room.
moderationRoomId: string;
// The id of the bot in charge of forwarding abuse reports to the moderation room.
moderationBotUserId: string;
};
/*
* A dialog for reporting an event.
*
* The actual content of the dialog will depend on two things:
*
* 1. Is `feature_report_to_moderators` enabled?
* 2. Does the room support moderation as per MSC3215, i.e. is there
* a well-formed state event `m.room.moderation.moderated_by`
* /`org.matrix.msc3215.room.moderation.moderated_by`?
*/
export default class ReportEventDialog extends React.Component<IProps, IState> {
// If the room supports moderation, the moderation information.
private moderation?: Moderation;
public constructor(props: IProps) {
super(props);
let moderatedByRoomId: string | null = null;
let moderatedByUserId: string | null = null;
if (SettingsStore.getValue("feature_report_to_moderators")) {
// The client supports reporting to moderators.
// Does the room support it, too?
// Extract state events to determine whether we should display
const client = MatrixClientPeg.safeGet();
const room = client.getRoom(props.mxEvent.getRoomId());
for (const stateEventType of MODERATED_BY_STATE_EVENT_TYPE) {
const stateEvent = room?.currentState.getStateEvents(stateEventType, stateEventType);
if (!stateEvent) {
continue;
}
if (Array.isArray(stateEvent)) {
// Internal error.
throw new TypeError(
`getStateEvents(${stateEventType}, ${stateEventType}) ` +
"should return at most one state event",
);
}
const event = stateEvent.event;
if (!("content" in event) || typeof event["content"] != "object") {
// The room is improperly configured.
// Display this debug message for the sake of moderators.
console.debug(
"Moderation error",
"state event",
stateEventType,
"should have an object field `content`, got",
event,
);
continue;
}
const content = event["content"];
if (!("room_id" in content) || typeof content["room_id"] != "string") {
// The room is improperly configured.
// Display this debug message for the sake of moderators.
console.debug(
"Moderation error",
"state event",
stateEventType,
"should have a string field `content.room_id`, got",
event,
);
continue;
}
if (!("user_id" in content) || typeof content["user_id"] != "string") {
// The room is improperly configured.
// Display this debug message for the sake of moderators.
console.debug(
"Moderation error",
"state event",
stateEventType,
"should have a string field `content.user_id`, got",
event,
);
continue;
}
moderatedByRoomId = content["room_id"];
moderatedByUserId = content["user_id"];
}
if (moderatedByRoomId && moderatedByUserId) {
// The room supports moderation.
this.moderation = {
moderationRoomId: moderatedByRoomId,
moderationBotUserId: moderatedByUserId,
};
}
}
this.state = {
// A free-form text describing the abuse.
reason: "",
busy: false,
err: undefined,
// If specified, the nature of the abuse, as specified by MSC3215.
nature: undefined,
ignoreUserToo: false, // default false, for now. Could easily be argued as default true
};
}
private onIgnoreUserTooChanged = (newVal: boolean): void => {
this.setState({ ignoreUserToo: newVal });
};
// The user has written down a freeform description of the abuse.
private onReasonChange = ({ target: { value: reason } }: ChangeEvent<HTMLTextAreaElement>): void => {
this.setState({ reason });
};
// The user has clicked on a nature.
private onNatureChosen = (e: React.FormEvent<HTMLInputElement>): void => {
this.setState({ nature: e.currentTarget.value as ExtendedNature });
};
// The user has clicked "cancel".
private onCancel = (): void => {
this.props.onFinished(false);
};
// The user has clicked "submit".
private onSubmit = async (): Promise<void> => {
let reason = this.state.reason || "";
reason = reason.trim();
if (this.moderation) {
// This room supports moderation.
// We need a nature.
// If the nature is `NATURE.OTHER` or `NON_STANDARD_NATURE.ADMIN`, we also need a `reason`.
if (
!this.state.nature ||
((this.state.nature == Nature.Other || this.state.nature == NonStandardValue.Admin) && !reason)
) {
this.setState({
err: _t("report_content|missing_reason"),
});
return;
}
} else {
// This room does not support moderation.
// We need a `reason`.
if (!reason) {
this.setState({
err: _t("report_content|missing_reason"),
});
return;
}
}
this.setState({
busy: true,
err: undefined,
});
try {
const client = MatrixClientPeg.safeGet();
const ev = this.props.mxEvent;
if (this.moderation && this.state.nature !== NonStandardValue.Admin) {
const nature = this.state.nature;
// Report to moderators through to the dedicated bot,
// as configured in the room's state events.
const dmRoomId = await ensureDMExists(client, this.moderation.moderationBotUserId);
if (!dmRoomId) {
throw new UserFriendlyError("report_content|error_create_room_moderation_bot");
}
await client.sendEvent(dmRoomId, ABUSE_EVENT_TYPE, {
event_id: ev.getId()!,
room_id: ev.getRoomId()!,
moderated_by_id: this.moderation.moderationRoomId,
nature,
reporter: client.getUserId()!,
comment: this.state.reason.trim(),
} satisfies AbuseEventContent);
} else {
// Report to homeserver admin through the dedicated Matrix API.
await client.reportEvent(ev.getRoomId()!, ev.getId()!, -100, this.state.reason.trim());
}
// if the user should also be ignored, do that
if (this.state.ignoreUserToo) {
await client.setIgnoredUsers([...client.getIgnoredUsers(), ev.getSender()!]);
}
this.props.onFinished(true);
} catch (e) {
logger.error(e);
this.setState({
busy: false,
err: e instanceof Error ? e.message : String(e),
});
}
};
public render(): React.ReactNode {
let error: JSX.Element | undefined;
if (this.state.err) {
error = <div className="error">{this.state.err}</div>;
}
let progress: JSX.Element | undefined;
if (this.state.busy) {
progress = (
<div className="progress">
<Spinner />
</div>
);
}
const ignoreUserCheckbox = (
<LabelledCheckbox
value={this.state.ignoreUserToo}
label={_t("report_content|ignore_user")}
byline={_t("report_content|hide_messages_from_user")}
onChange={this.onIgnoreUserTooChanged}
disabled={this.state.busy}
/>
);
const adminMessageMD = SdkConfig.getObject("report_event")?.get("admin_message_md", "adminMessageMD");
let adminMessage: JSX.Element | undefined;
if (adminMessageMD) {
const html = new Markdown(adminMessageMD).toHTML({ externalLinks: true });
adminMessage = <p dangerouslySetInnerHTML={{ __html: html }} />;
}
if (this.moderation) {
// Display report-to-moderator dialog.
// We let the user pick a nature.
const client = MatrixClientPeg.safeGet();
const homeServerName = SdkConfig.get("validated_server_config")!.hsName;
let subtitle: string;
switch (this.state.nature) {
case Nature.Disagreement:
subtitle = _t("report_content|nature_disagreement");
break;
case Nature.Toxic:
subtitle = _t("report_content|nature_toxic");
break;
case Nature.Illegal:
subtitle = _t("report_content|nature_illegal");
break;
case Nature.Spam:
subtitle = _t("report_content|nature_spam");
break;
case NonStandardValue.Admin:
if (client.isRoomEncrypted(this.props.mxEvent.getRoomId()!)) {
subtitle = _t("report_content|nature_nonstandard_admin_encrypted", {
homeserver: homeServerName,
});
} else {
subtitle = _t("report_content|nature_nonstandard_admin", { homeserver: homeServerName });
}
break;
case Nature.Other:
subtitle = _t("report_content|nature_other");
break;
default:
subtitle = _t("report_content|nature");
break;
}
return (
<BaseDialog
className="mx_ReportEventDialog"
onFinished={this.props.onFinished}
title={_t("action|report_content")}
contentId="mx_ReportEventDialog"
>
<div>
<StyledRadioButton
name="nature"
value={Nature.Disagreement}
checked={this.state.nature == Nature.Disagreement}
onChange={this.onNatureChosen}
>
{_t("report_content|disagree")}
</StyledRadioButton>
<StyledRadioButton
name="nature"
value={Nature.Toxic}
checked={this.state.nature == Nature.Toxic}
onChange={this.onNatureChosen}
>
{_t("report_content|toxic_behaviour")}
</StyledRadioButton>
<StyledRadioButton
name="nature"
value={Nature.Illegal}
checked={this.state.nature == Nature.Illegal}
onChange={this.onNatureChosen}
>
{_t("report_content|illegal_content")}
</StyledRadioButton>
<StyledRadioButton
name="nature"
value={Nature.Spam}
checked={this.state.nature == Nature.Spam}
onChange={this.onNatureChosen}
>
{_t("report_content|spam_or_propaganda")}
</StyledRadioButton>
<StyledRadioButton
name="nature"
value={NonStandardValue.Admin}
checked={this.state.nature == NonStandardValue.Admin}
onChange={this.onNatureChosen}
>
{_t("report_content|report_entire_room")}
</StyledRadioButton>
<StyledRadioButton
name="nature"
value={Nature.Other}
checked={this.state.nature == Nature.Other}
onChange={this.onNatureChosen}
>
{_t("report_content|other_label")}
</StyledRadioButton>
<p>{subtitle}</p>
<Field
className="mx_ReportEventDialog_reason"
element="textarea"
label={_t("room_settings|permissions|ban_reason")}
rows={5}
onChange={this.onReasonChange}
value={this.state.reason}
disabled={this.state.busy}
/>
{progress}
{error}
{ignoreUserCheckbox}
</div>
<DialogButtons
primaryButton={_t("action|send_report")}
onPrimaryButtonClick={this.onSubmit}
focus={true}
onCancel={this.onCancel}
disabled={this.state.busy}
/>
</BaseDialog>
);
}
// Report to homeserver admin.
// Currently, the API does not support natures.
return (
<BaseDialog
className="mx_ReportEventDialog"
onFinished={this.props.onFinished}
title={_t("report_content|report_content_to_homeserver")}
contentId="mx_ReportEventDialog"
>
<div className="mx_ReportEventDialog" id="mx_ReportEventDialog">
<p>{_t("report_content|description")}</p>
{adminMessage}
<Field
className="mx_ReportEventDialog_reason"
element="textarea"
label={_t("room_settings|permissions|ban_reason")}
rows={5}
onChange={this.onReasonChange}
value={this.state.reason}
disabled={this.state.busy}
/>
{progress}
{error}
{ignoreUserCheckbox}
</div>
<DialogButtons
primaryButton={_t("action|send_report")}
onPrimaryButtonClick={this.onSubmit}
focus={true}
onCancel={this.onCancel}
disabled={this.state.busy}
/>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,261 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2019 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { RoomEvent, Room, RoomStateEvent, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix";
import TabbedView, { Tab } from "../../structures/TabbedView";
import { _t, _td } from "../../../languageHandler";
import AdvancedRoomSettingsTab from "../settings/tabs/room/AdvancedRoomSettingsTab";
import RolesRoomSettingsTab from "../settings/tabs/room/RolesRoomSettingsTab";
import GeneralRoomSettingsTab from "../settings/tabs/room/GeneralRoomSettingsTab";
import SecurityRoomSettingsTab from "../settings/tabs/room/SecurityRoomSettingsTab";
import NotificationSettingsTab from "../settings/tabs/room/NotificationSettingsTab";
import BridgeSettingsTab from "../settings/tabs/room/BridgeSettingsTab";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import dis from "../../../dispatcher/dispatcher";
import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import BaseDialog from "./BaseDialog";
import { Action } from "../../../dispatcher/actions";
import { VoipRoomSettingsTab } from "../settings/tabs/room/VoipRoomSettingsTab";
import { ActionPayload } from "../../../dispatcher/payloads";
import { NonEmptyArray } from "../../../@types/common";
import { PollHistoryTab } from "../settings/tabs/room/PollHistoryTab";
import ErrorBoundary from "../elements/ErrorBoundary";
import { PeopleRoomSettingsTab } from "../settings/tabs/room/PeopleRoomSettingsTab";
export const enum RoomSettingsTab {
General = "ROOM_GENERAL_TAB",
People = "ROOM_PEOPLE_TAB",
Voip = "ROOM_VOIP_TAB",
Security = "ROOM_SECURITY_TAB",
Roles = "ROOM_ROLES_TAB",
Notifications = "ROOM_NOTIFICATIONS_TAB",
Bridges = "ROOM_BRIDGES_TAB",
Advanced = "ROOM_ADVANCED_TAB",
PollHistory = "ROOM_POLL_HISTORY_TAB",
}
interface IProps {
roomId: string;
onFinished: (success?: boolean) => void;
initialTabId?: RoomSettingsTab;
}
interface IState {
room: Room;
activeTabId: RoomSettingsTab;
}
class RoomSettingsDialog extends React.Component<IProps, IState> {
private dispatcherRef?: string;
public constructor(props: IProps) {
super(props);
const room = this.getRoom();
this.state = { room, activeTabId: props.initialTabId || RoomSettingsTab.General };
}
public componentDidMount(): void {
this.dispatcherRef = dis.register(this.onAction);
MatrixClientPeg.safeGet().on(RoomEvent.Name, this.onRoomName);
MatrixClientPeg.safeGet().on(RoomStateEvent.Events, this.onStateEvent);
this.onRoomName();
}
public componentDidUpdate(): void {
if (this.props.roomId !== this.state.room.roomId) {
const room = this.getRoom();
this.setState({ room });
}
}
public componentWillUnmount(): void {
if (this.dispatcherRef) {
dis.unregister(this.dispatcherRef);
}
MatrixClientPeg.get()?.removeListener(RoomEvent.Name, this.onRoomName);
MatrixClientPeg.get()?.removeListener(RoomStateEvent.Events, this.onStateEvent);
}
/**
* Get room from client
* @returns Room
* @throws when room is not found
*/
private getRoom(): Room {
const room = MatrixClientPeg.safeGet().getRoom(this.props.roomId)!;
// something is really wrong if we encounter this
if (!room) {
throw new Error(`Cannot find room ${this.props.roomId}`);
}
return room;
}
private onAction = (payload: ActionPayload): void => {
// When view changes below us, close the room settings
// whilst the modal is open this can only be triggered when someone hits Leave Room
if (payload.action === Action.ViewHomePage) {
this.props.onFinished(true);
}
};
private onRoomName = (): void => {
// rerender when the room name changes
this.forceUpdate();
};
private onStateEvent = (event: MatrixEvent): void => {
if (event.getType() === EventType.RoomJoinRules) this.forceUpdate();
};
private onTabChange = (tabId: RoomSettingsTab): void => {
this.setState({ activeTabId: tabId });
};
private getTabs(): NonEmptyArray<Tab<RoomSettingsTab>> {
const tabs: Tab<RoomSettingsTab>[] = [];
tabs.push(
new Tab(
RoomSettingsTab.General,
_td("common|general"),
"mx_RoomSettingsDialog_settingsIcon",
<GeneralRoomSettingsTab room={this.state.room} />,
"RoomSettingsGeneral",
),
);
if (SettingsStore.getValue("feature_ask_to_join") && this.state.room.getJoinRule() === "knock") {
tabs.push(
new Tab(
RoomSettingsTab.People,
_td("common|people"),
"mx_RoomSettingsDialog_peopleIcon",
<PeopleRoomSettingsTab room={this.state.room} />,
),
);
}
if (SettingsStore.getValue("feature_group_calls")) {
tabs.push(
new Tab(
RoomSettingsTab.Voip,
_td("settings|voip|title"),
"mx_RoomSettingsDialog_voiceIcon",
<VoipRoomSettingsTab room={this.state.room} />,
),
);
}
tabs.push(
new Tab(
RoomSettingsTab.Security,
_td("room_settings|security|title"),
"mx_RoomSettingsDialog_securityIcon",
<SecurityRoomSettingsTab room={this.state.room} closeSettingsFn={() => this.props.onFinished(true)} />,
"RoomSettingsSecurityPrivacy",
),
);
tabs.push(
new Tab(
RoomSettingsTab.Roles,
_td("room_settings|permissions|title"),
"mx_RoomSettingsDialog_rolesIcon",
<RolesRoomSettingsTab room={this.state.room} />,
"RoomSettingsRolesPermissions",
),
);
tabs.push(
new Tab(
RoomSettingsTab.Notifications,
_td("notifications|enable_prompt_toast_title"),
"mx_RoomSettingsDialog_notificationsIcon",
(
<NotificationSettingsTab
roomId={this.state.room.roomId}
closeSettingsFn={() => this.props.onFinished(true)}
/>
),
"RoomSettingsNotifications",
),
);
if (SettingsStore.getValue("feature_bridge_state")) {
tabs.push(
new Tab(
RoomSettingsTab.Bridges,
_td("room_settings|bridges|title"),
"mx_RoomSettingsDialog_bridgesIcon",
<BridgeSettingsTab room={this.state.room} />,
"RoomSettingsBridges",
),
);
}
tabs.push(
new Tab(
RoomSettingsTab.PollHistory,
_td("right_panel|polls_button"),
"mx_RoomSettingsDialog_pollsIcon",
<PollHistoryTab room={this.state.room} onFinished={() => this.props.onFinished(true)} />,
),
);
if (SettingsStore.getValue(UIFeature.AdvancedSettings)) {
tabs.push(
new Tab(
RoomSettingsTab.Advanced,
_td("common|advanced"),
"mx_RoomSettingsDialog_warningIcon",
(
<AdvancedRoomSettingsTab
room={this.state.room}
closeSettingsFn={() => this.props.onFinished(true)}
/>
),
"RoomSettingsAdvanced",
),
);
}
return tabs as NonEmptyArray<Tab<RoomSettingsTab>>;
}
public render(): React.ReactNode {
const roomName = this.state.room.name;
return (
<BaseDialog
className="mx_RoomSettingsDialog"
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("room_settings|title", { roomName })}
>
<div className="mx_SettingsDialog_content">
<TabbedView
tabs={this.getTabs()}
activeTabId={this.state.activeTabId}
screenName="RoomSettings"
onChange={this.onTabChange}
/>
</div>
</BaseDialog>
);
}
}
const WrappedRoomSettingsDialog: React.FC<IProps> = (props) => (
<ErrorBoundary>
<RoomSettingsDialog {...props} />
</ErrorBoundary>
);
export default WrappedRoomSettingsDialog;

View file

@ -0,0 +1,99 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2018-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { Room } from "matrix-js-sdk/src/matrix";
import Modal from "../../../Modal";
import { _t } from "../../../languageHandler";
import { upgradeRoom } from "../../../utils/RoomUpgrade";
import BaseDialog from "./BaseDialog";
import ErrorDialog from "./ErrorDialog";
import DialogButtons from "../elements/DialogButtons";
import Spinner from "../elements/Spinner";
interface IProps {
room: Room;
onFinished(upgrade?: boolean): void;
}
interface IState {
busy: boolean;
}
export default class RoomUpgradeDialog extends React.Component<IProps, IState> {
private targetVersion?: string;
public state = {
busy: true,
};
public async componentDidMount(): Promise<void> {
const recommended = await this.props.room.getRecommendedVersion();
this.targetVersion = recommended.version;
this.setState({ busy: false });
}
private onCancelClick = (): void => {
this.props.onFinished(false);
};
private onUpgradeClick = (): void => {
this.setState({ busy: true });
upgradeRoom(this.props.room, this.targetVersion!, false, false)
.then(() => {
this.props.onFinished(true);
})
.catch((err) => {
Modal.createDialog(ErrorDialog, {
title: _t("room_settings|advanced|error_upgrade_title"),
description:
err && err.message ? err.message : _t("room_settings|advanced|error_upgrade_description"),
});
})
.finally(() => {
this.setState({ busy: false });
});
};
public render(): React.ReactNode {
let buttons: JSX.Element;
if (this.state.busy) {
buttons = <Spinner />;
} else {
buttons = (
<DialogButtons
primaryButton={_t("room_settings|advanced|upgrade_button", { version: this.targetVersion })}
primaryButtonClass="danger"
hasCancel={true}
onPrimaryButtonClick={this.onUpgradeClick}
onCancel={this.onCancelClick}
/>
);
}
return (
<BaseDialog
className="mx_RoomUpgradeDialog"
onFinished={this.props.onFinished}
title={_t("room_settings|advanced|upgrade_dialog_title")}
contentId="mx_Dialog_content"
hasCancel={true}
>
<p>{_t("room_settings|advanced|upgrade_dialog_description")}</p>
<ol>
<li>{_t("room_settings|advanced|upgrade_dialog_description_1")}</li>
<li>{_t("room_settings|advanced|upgrade_dialog_description_2")}</li>
<li>{_t("room_settings|advanced|upgrade_dialog_description_3")}</li>
<li>{_t("room_settings|advanced|upgrade_dialog_description_4")}</li>
</ol>
{buttons}
</BaseDialog>
);
}
}

View file

@ -0,0 +1,205 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ReactNode, SyntheticEvent } from "react";
import { EventType, JoinRule } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import Modal from "../../../Modal";
import BugReportDialog from "./BugReportDialog";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import ProgressBar from "../elements/ProgressBar";
import AccessibleButton from "../elements/AccessibleButton";
export interface IFinishedOpts {
continue: boolean;
invite: boolean;
}
interface IProps {
roomId: string;
targetVersion: string;
description?: ReactNode;
doUpgrade?(opts: IFinishedOpts, fn: (progressText: string, progress: number, total: number) => void): Promise<void>;
onFinished(opts?: IFinishedOpts): void;
}
interface Progress {
text: string;
progress: number;
total: number;
}
interface IState {
inviteUsersToNewRoom: boolean;
progress?: Progress;
}
export default class RoomUpgradeWarningDialog extends React.Component<IProps, IState> {
private readonly joinRule: JoinRule;
private readonly isInviteOrKnockRoom: boolean;
private readonly currentVersion?: string;
public constructor(props: IProps) {
super(props);
const room = MatrixClientPeg.safeGet().getRoom(this.props.roomId);
const joinRules = room?.currentState.getStateEvents(EventType.RoomJoinRules, "");
this.joinRule = joinRules?.getContent()["join_rule"] ?? JoinRule.Invite;
this.isInviteOrKnockRoom = [JoinRule.Invite, JoinRule.Knock].includes(this.joinRule);
this.currentVersion = room?.getVersion();
this.state = {
inviteUsersToNewRoom: true,
};
}
private onProgressCallback = (text: string, progress: number, total: number): void => {
this.setState({
progress: {
text,
progress,
total,
},
});
};
private onContinue = async (): Promise<void> => {
const opts = {
continue: true,
invite: this.isInviteOrKnockRoom && this.state.inviteUsersToNewRoom,
};
await this.props.doUpgrade?.(opts, this.onProgressCallback);
this.props.onFinished(opts);
};
private onCancel = (): void => {
this.props.onFinished({ continue: false, invite: false });
};
private onInviteUsersToggle = (inviteUsersToNewRoom: boolean): void => {
this.setState({ inviteUsersToNewRoom });
};
private openBugReportDialog = (e: SyntheticEvent): void => {
e.preventDefault();
e.stopPropagation();
Modal.createDialog(BugReportDialog, {});
};
public render(): React.ReactNode {
const brand = SdkConfig.get().brand;
let inviteToggle: JSX.Element | undefined;
if (this.isInviteOrKnockRoom) {
inviteToggle = (
<LabelledToggleSwitch
value={this.state.inviteUsersToNewRoom}
onChange={this.onInviteUsersToggle}
label={_t("room_settings|advanced|upgrade_warning_dialog_invite_label")}
/>
);
}
let title: string;
switch (this.joinRule) {
case JoinRule.Invite:
title = _t("room_settings|advanced|upgrade_warning_dialog_title_private");
break;
case JoinRule.Public:
title = _t("room_settings|advanced|upgrade_dwarning_ialog_title_public");
break;
default:
title = _t("room_settings|advanced|upgrade_warning_dialog_title");
}
let bugReports = <p>{_t("room_settings|advanced|upgrade_warning_dialog_report_bug_prompt", { brand })}</p>;
if (SdkConfig.get().bug_report_endpoint_url) {
bugReports = (
<p>
{_t(
"room_settings|advanced|upgrade_warning_dialog_report_bug_prompt_link",
{
brand,
},
{
a: (sub) => {
return (
<AccessibleButton kind="link_inline" onClick={this.openBugReportDialog}>
{sub}
</AccessibleButton>
);
},
},
)}
</p>
);
}
let footer: JSX.Element;
if (this.state.progress) {
footer = (
<span className="mx_RoomUpgradeWarningDialog_progress">
<ProgressBar value={this.state.progress.progress} max={this.state.progress.total} />
<div className="mx_RoomUpgradeWarningDialog_progressText">{this.state.progress.text}</div>
</span>
);
} else {
footer = (
<DialogButtons
primaryButton={_t("action|upgrade")}
onPrimaryButtonClick={this.onContinue}
cancelButton={_t("action|cancel")}
onCancel={this.onCancel}
/>
);
}
return (
<BaseDialog
className="mx_RoomUpgradeWarningDialog"
hasCancel={true}
fixedWidth={false}
onFinished={this.props.onFinished}
title={title}
>
<div>
<p>{this.props.description || _t("room_settings|advanced|upgrade_warning_dialog_description")}</p>
<p>
{_t(
"room_settings|advanced|upgrade_warning_dialog_explainer",
{},
{
b: (sub) => <strong>{sub}</strong>,
},
)}
</p>
{bugReports}
<p>
{_t(
"room_settings|advanced|upgrade_warning_dialog_footer",
{},
{
oldVersion: () => <code>{this.currentVersion}</code>,
newVersion: () => <code>{this.props.targetVersion}</code>,
},
)}
</p>
{inviteToggle}
</div>
{footer}
</BaseDialog>
);
}
}

View file

@ -0,0 +1,117 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { FormEvent } from "react";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import FocusLock from "react-focus-lock";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { _t } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
export interface IScrollableBaseState {
canSubmit: boolean;
title: string;
actionLabel: string;
cancelLabel?: string;
}
/**
* Scrollable dialog base from Compound (Web Components).
*/
export default abstract class ScrollableBaseModal<
TProps extends { onFinished?: (...args: any[]) => void },
TState extends IScrollableBaseState,
> extends React.PureComponent<TProps, TState> {
protected constructor(props: TProps) {
super(props);
}
protected get matrixClient(): MatrixClient {
// XXX: The contract on MatrixClientContext says it is only available within a LoggedInView subtree,
// given that modals function outside the MatrixChat React tree this simulates that. We don't want to
// use safeGet as it throwing would mean we cannot use modals whilst the user isn't logged in.
// The longer term solution is to move our ModalManager into the React tree to inherit contexts properly.
return MatrixClientPeg.get()!;
}
private onKeyDown = (e: KeyboardEvent | React.KeyboardEvent): void => {
const action = getKeyBindingsManager().getAccessibilityAction(e);
switch (action) {
case KeyBindingAction.Escape:
e.stopPropagation();
e.preventDefault();
this.cancel();
break;
}
};
private onCancel = (): void => {
this.cancel();
};
private onSubmit = (e: MouseEvent | FormEvent): void => {
e.stopPropagation();
e.preventDefault();
if (!this.state.canSubmit) return; // pretend the submit button was disabled
this.submit();
};
protected abstract cancel(): void;
protected abstract submit(): void;
protected abstract renderContent(): React.ReactNode;
public render(): React.ReactNode {
return (
<MatrixClientContext.Provider value={this.matrixClient}>
<FocusLock
returnFocus={true}
lockProps={{
onKeyDown: this.onKeyDown,
role: "dialog",
["aria-labelledby"]: "mx_CompoundDialog_title",
// Like BaseDialog, we'll just point this at the whole content
["aria-describedby"]: "mx_CompoundDialog_content",
}}
className="mx_CompoundDialog mx_ScrollableBaseDialog"
>
<div className="mx_CompoundDialog_header">
<h1>{this.state.title}</h1>
</div>
<AccessibleButton
onClick={this.onCancel}
className="mx_CompoundDialog_cancelButton"
aria-label={_t("dialog_close_label")}
/>
<form onSubmit={this.onSubmit} className="mx_CompoundDialog_form">
<div className="mx_CompoundDialog_content">{this.renderContent()}</div>
<div className="mx_CompoundDialog_footer">
<AccessibleButton onClick={this.onCancel} kind="primary_outline">
{this.state.cancelLabel ?? _t("action|cancel")}
</AccessibleButton>
<AccessibleButton
onClick={this.onSubmit}
kind="primary"
disabled={!this.state.canSubmit}
type="submit"
element="button"
className="mx_Dialog_nonDialogButton"
>
{this.state.actionLabel}
</AccessibleButton>
</div>
</form>
</FocusLock>
</MatrixClientContext.Provider>
);
}
}

View file

@ -0,0 +1,118 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ReactNode } from "react";
import BaseDialog from "./BaseDialog";
import { _t } from "../../../languageHandler";
import { EchoStore } from "../../../stores/local-echo/EchoStore";
import { formatTime } from "../../../DateUtils";
import SettingsStore from "../../../settings/SettingsStore";
import { RoomEchoContext } from "../../../stores/local-echo/RoomEchoContext";
import RoomAvatar from "../avatars/RoomAvatar";
import { TransactionStatus } from "../../../stores/local-echo/EchoTransaction";
import Spinner from "../elements/Spinner";
import AccessibleButton from "../elements/AccessibleButton";
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
interface IProps {
onFinished(): void;
}
export default class ServerOfflineDialog extends React.PureComponent<IProps> {
public componentDidMount(): void {
EchoStore.instance.on(UPDATE_EVENT, this.onEchosUpdated);
}
public componentWillUnmount(): void {
EchoStore.instance.off(UPDATE_EVENT, this.onEchosUpdated);
}
private onEchosUpdated = (): void => {
this.forceUpdate(); // no state to worry about
};
private renderTimeline(): ReactNode[] {
return EchoStore.instance.contexts.map((c, i) => {
if (!c.firstFailedTime) return null; // not useful
if (!(c instanceof RoomEchoContext))
throw new Error("Cannot render unknown context: " + c.constructor.name);
const header = (
<div className="mx_ServerOfflineDialog_content_context_timeline_header">
<RoomAvatar size="24px" room={c.room} />
<span>{c.room.name}</span>
</div>
);
const entries = c.transactions
.filter((t) => t.status === TransactionStatus.Error || t.didPreviouslyFail)
.map((t, j) => {
let button = <Spinner w={19} h={19} />;
if (t.status === TransactionStatus.Error) {
button = (
<AccessibleButton kind="link" onClick={() => t.run()}>
{_t("action|resend")}
</AccessibleButton>
);
}
return (
<div className="mx_ServerOfflineDialog_content_context_txn" key={`txn-${j}`}>
<span className="mx_ServerOfflineDialog_content_context_txn_desc">{t.auditName}</span>
{button}
</div>
);
});
return (
<div className="mx_ServerOfflineDialog_content_context" key={`context-${i}`}>
<div className="mx_ServerOfflineDialog_content_context_timestamp">
{formatTime(c.firstFailedTime, SettingsStore.getValue("showTwelveHourTimestamps"))}
</div>
<div className="mx_ServerOfflineDialog_content_context_timeline">
{header}
{entries}
</div>
</div>
);
});
}
public render(): React.ReactNode {
let timeline = this.renderTimeline().filter((c) => !!c); // remove nulls for next check
if (timeline.length === 0) {
timeline = [<div key={1}>{_t("server_offline|empty_timeline")}</div>];
}
const serverName = MatrixClientPeg.safeGet().getDomain();
return (
<BaseDialog
title={_t("server_offline|title")}
className="mx_ServerOfflineDialog"
contentId="mx_Dialog_content"
onFinished={this.props.onFinished}
hasCancel={true}
>
<div className="mx_ServerOfflineDialog_content">
<p>{_t("server_offline|description")}</p>
<ul>
<li>{_t("server_offline|description_1", { serverName })}</li>
<li>{_t("server_offline|description_2")}</li>
<li>{_t("server_offline|description_3")}</li>
<li>{_t("server_offline|description_4")}</li>
<li>{_t("server_offline|description_5")}</li>
<li>{_t("server_offline|description_6")}</li>
<li>{_t("server_offline|description_7")}</li>
<li>{_t("server_offline|description_8")}</li>
</ul>
<hr />
<h2>{_t("server_offline|recent_changes_heading")}</h2>
{timeline}
</div>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,249 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ChangeEvent, createRef, SyntheticEvent } from "react";
import { AutoDiscovery } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils";
import BaseDialog from "./BaseDialog";
import { _t, UserFriendlyError } from "../../../languageHandler";
import AccessibleButton from "../elements/AccessibleButton";
import SdkConfig from "../../../SdkConfig";
import Field from "../elements/Field";
import StyledRadioButton from "../elements/StyledRadioButton";
import TextWithTooltip from "../elements/TextWithTooltip";
import withValidation, { IFieldState, IValidationResult } from "../elements/Validation";
import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
import ExternalLink from "../elements/ExternalLink";
interface IProps {
title?: string;
serverConfig: ValidatedServerConfig;
onFinished(config?: ValidatedServerConfig): void;
}
interface IState {
defaultChosen: boolean;
otherHomeserver: string;
}
export default class ServerPickerDialog extends React.PureComponent<IProps, IState> {
private readonly defaultServer: ValidatedServerConfig;
private readonly fieldRef = createRef<Field>();
private validatedConf?: ValidatedServerConfig;
public constructor(props: IProps) {
super(props);
const config = SdkConfig.get();
this.defaultServer = config["validated_server_config"]!;
const { serverConfig } = this.props;
let otherHomeserver = "";
if (!serverConfig.isDefault) {
if (serverConfig.isNameResolvable && serverConfig.hsName) {
otherHomeserver = serverConfig.hsName;
} else {
otherHomeserver = serverConfig.hsUrl;
}
}
this.state = {
defaultChosen: serverConfig.isDefault,
otherHomeserver,
};
}
private onDefaultChosen = (): void => {
this.setState({ defaultChosen: true });
};
private onOtherChosen = (): void => {
this.setState({ defaultChosen: false });
};
private onHomeserverChange = (ev: ChangeEvent<HTMLInputElement>): void => {
this.setState({ otherHomeserver: ev.target.value });
};
private validate = withValidation<this, { error?: string }>({
deriveData: async ({ value }): Promise<{ error?: string }> => {
let hsUrl = (value ?? "").trim(); // trim to account for random whitespace
// if the URL has no protocol, try validate it as a serverName via well-known
if (!hsUrl.includes("://")) {
try {
const discoveryResult = await AutoDiscovery.findClientConfig(hsUrl);
this.validatedConf = await AutoDiscoveryUtils.buildValidatedConfigFromDiscovery(
hsUrl,
discoveryResult,
);
return {}; // we have a validated config, we don't need to try the other paths
} catch (e) {
logger.error(`Attempted ${hsUrl} as a server_name but it failed`, e);
}
}
// if we got to this stage then either the well-known failed or the URL had a protocol specified,
// so validate statically only. If the URL has no protocol, default to https.
if (!hsUrl.includes("://")) {
hsUrl = "https://" + hsUrl;
}
try {
this.validatedConf = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(hsUrl);
return {};
} catch (e) {
logger.error(e);
const stateForError = AutoDiscoveryUtils.authComponentStateForError(e);
if (stateForError.serverErrorIsFatal) {
let error = _t("auth|server_picker_failed_validate_homeserver");
if (e instanceof UserFriendlyError && e.translatedMessage) {
error = e.translatedMessage;
}
return { error };
}
// try to carry on anyway
try {
this.validatedConf = await AutoDiscoveryUtils.validateServerConfigWithStaticUrls(
hsUrl,
undefined,
true,
);
return {};
} catch (e) {
logger.error(e);
return { error: _t("auth|server_picker_invalid_url") };
}
}
},
rules: [
{
key: "required",
test: ({ value, allowEmpty }) => allowEmpty || !!value,
invalid: () => _t("auth|server_picker_required"),
},
{
key: "valid",
test: async function ({ value }, { error }): Promise<boolean> {
if (!value) return true;
return !error;
},
invalid: function ({ error }) {
return error ?? null;
},
},
],
});
private onHomeserverValidate = (fieldState: IFieldState): Promise<IValidationResult> => this.validate(fieldState);
private onSubmit = async (ev: SyntheticEvent): Promise<void> => {
ev.preventDefault();
if (this.state.defaultChosen) {
this.props.onFinished(this.defaultServer);
return;
}
const valid = await this.fieldRef.current?.validate({ allowEmpty: false });
if (!valid) {
this.fieldRef.current?.focus();
this.fieldRef.current?.validate({ allowEmpty: false, focused: true });
return;
}
this.props.onFinished(this.validatedConf);
};
public render(): React.ReactNode {
let text: string | undefined;
if (this.defaultServer.hsName === "matrix.org") {
text = _t("auth|server_picker_matrix.org");
}
let defaultServerName: React.ReactNode = this.defaultServer.hsName;
if (this.defaultServer.hsNameIsDifferent) {
defaultServerName = (
<TextWithTooltip className="mx_Login_underlinedServerName" tooltip={this.defaultServer.hsUrl}>
{this.defaultServer.hsName}
</TextWithTooltip>
);
}
return (
<BaseDialog
title={this.props.title || _t("auth|server_picker_title")}
className="mx_ServerPickerDialog"
contentId="mx_ServerPickerDialog"
onFinished={this.props.onFinished}
fixedWidth={false}
hasCancel={true}
>
<form className="mx_Dialog_content" id="mx_ServerPickerDialog" onSubmit={this.onSubmit}>
<p>
{_t("auth|server_picker_intro")} {text}
</p>
<StyledRadioButton
name="defaultChosen"
value="true"
checked={this.state.defaultChosen}
onChange={this.onDefaultChosen}
data-testid="defaultHomeserver"
>
{defaultServerName}
</StyledRadioButton>
<StyledRadioButton
name="defaultChosen"
value="false"
className="mx_ServerPickerDialog_otherHomeserverRadio"
checked={!this.state.defaultChosen}
onChange={this.onOtherChosen}
childrenInLabel={false}
aria-label={_t("auth|server_picker_custom")}
>
<Field
type="text"
className="mx_ServerPickerDialog_otherHomeserver"
label={_t("auth|server_picker_custom")}
onChange={this.onHomeserverChange}
onFocus={this.onOtherChosen}
ref={this.fieldRef}
onValidate={this.onHomeserverValidate}
value={this.state.otherHomeserver}
validateOnChange={false}
validateOnFocus={false}
autoFocus={true}
id="mx_homeserverInput"
/>
</StyledRadioButton>
<p>{_t("auth|server_picker_explainer")}</p>
<AccessibleButton className="mx_ServerPickerDialog_continue" kind="primary" onClick={this.onSubmit}>
{_t("action|continue")}
</AccessibleButton>
<h2>{_t("action|learn_more")}</h2>
<ExternalLink
href="https://matrix.org/docs/matrix-concepts/elements-of-matrix/#homeserver"
target="_blank"
rel="noreferrer noopener"
>
{_t("auth|server_picker_learn_more")}
</ExternalLink>
</form>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,44 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { _t } from "../../../languageHandler";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
interface Props {
onFinished(reset?: boolean): void;
}
export default class SeshatResetDialog extends React.PureComponent<Props> {
public render(): React.ReactNode {
return (
<BaseDialog
hasCancel={true}
onFinished={this.props.onFinished.bind(null, false)}
title={_t("seshat|reset_title")}
>
<div>
<p>
{_t("seshat|reset_description")}
<br />
{_t("seshat|reset_explainer")}
</p>
</div>
<DialogButtons
primaryButton={_t("seshat|reset_button")}
onPrimaryButtonClick={this.props.onFinished.bind(null, true)}
primaryButtonClass="danger"
cancelButton={_t("action|cancel")}
onCancel={this.props.onFinished.bind(null, false)}
/>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,102 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd
Copyright 2017 Vector Creations Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import SdkConfig from "../../../SdkConfig";
import Modal from "../../../Modal";
import { _t } from "../../../languageHandler";
import QuestionDialog from "./QuestionDialog";
import BugReportDialog from "./BugReportDialog";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
interface IProps {
error: unknown;
onFinished(clear?: boolean): void;
}
export default class SessionRestoreErrorDialog extends React.Component<IProps> {
private sendBugReport = (): void => {
Modal.createDialog(BugReportDialog, {
error: this.props.error,
});
};
private onClearStorageClick = (): void => {
Modal.createDialog(QuestionDialog, {
title: _t("action|sign_out"),
description: <div>{_t("error|session_restore|clear_storage_description")}</div>,
button: _t("action|sign_out"),
danger: true,
onFinished: this.props.onFinished,
});
};
private onRefreshClick = (): void => {
// Is this likely to help? Probably not, but giving only one button
// that clears your storage seems awful.
window.location.reload();
};
public render(): React.ReactNode {
const brand = SdkConfig.get().brand;
const clearStorageButton = (
<button onClick={this.onClearStorageClick} className="danger">
{_t("error|session_restore|clear_storage_button")}
</button>
);
let dialogButtons;
if (SdkConfig.get().bug_report_endpoint_url) {
dialogButtons = (
<DialogButtons
primaryButton={_t("bug_reporting|send_logs")}
onPrimaryButtonClick={this.sendBugReport}
focus={true}
hasCancel={false}
>
{clearStorageButton}
</DialogButtons>
);
} else {
dialogButtons = (
<DialogButtons
primaryButton={_t("action|refresh")}
onPrimaryButtonClick={this.onRefreshClick}
focus={true}
hasCancel={false}
>
{clearStorageButton}
</DialogButtons>
);
}
return (
<BaseDialog
className="mx_ErrorDialog"
onFinished={this.props.onFinished}
title={_t("error|session_restore|title")}
contentId="mx_Dialog_content"
hasCancel={false}
>
<div className="mx_Dialog_content" id="mx_Dialog_content">
<p>{_t("error|session_restore|description_1")}</p>
<p>{_t("error|session_restore|description_2", { brand })}</p>
<p>{_t("error|session_restore|description_3")}</p>
</div>
{dialogButtons}
</BaseDialog>
);
}
}

View file

@ -0,0 +1,172 @@
/*
Copyright 2018-2024 New Vector Ltd.
Copyright 2017 Vector Creations Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixError } from "matrix-js-sdk/src/matrix";
import * as Email from "../../../email";
import AddThreepid from "../../../AddThreepid";
import { _t, UserFriendlyError } from "../../../languageHandler";
import Modal from "../../../Modal";
import Spinner from "../elements/Spinner";
import ErrorDialog, { extractErrorMessageFromError } from "./ErrorDialog";
import QuestionDialog from "./QuestionDialog";
import BaseDialog from "./BaseDialog";
import EditableText from "../elements/EditableText";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
interface IProps {
title: string;
onFinished(ok?: boolean): void;
}
interface IState {
emailAddress: string;
emailBusy: boolean;
}
/*
* Prompt the user to set an email address.
*
* On success, `onFinished(true)` is called.
*/
export default class SetEmailDialog extends React.Component<IProps, IState> {
private addThreepid?: AddThreepid;
public constructor(props: IProps) {
super(props);
this.state = {
emailAddress: "",
emailBusy: false,
};
}
private onEmailAddressChanged = (value: string): void => {
this.setState({
emailAddress: value,
});
};
private onSubmit = (): void => {
const emailAddress = this.state.emailAddress;
if (!Email.looksValid(emailAddress)) {
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_invalid_email"),
description: _t("settings|general|error_invalid_email_detail"),
});
return;
}
this.addThreepid = new AddThreepid(MatrixClientPeg.safeGet());
this.addThreepid.addEmailAddress(emailAddress).then(
() => {
Modal.createDialog(QuestionDialog, {
title: _t("auth|set_email|verification_pending_title"),
description: _t("auth|set_email|verification_pending_description"),
button: _t("action|continue"),
onFinished: this.onEmailDialogFinished,
});
},
(err) => {
this.setState({ emailBusy: false });
logger.error("Unable to add email address " + emailAddress + " " + err);
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_add_email"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
},
);
this.setState({ emailBusy: true });
};
private onCancelled = (): void => {
this.props.onFinished(false);
};
private onEmailDialogFinished = (ok: boolean): void => {
if (ok) {
this.verifyEmailAddress();
} else {
this.setState({ emailBusy: false });
}
};
private verifyEmailAddress(): void {
this.addThreepid?.checkEmailLinkClicked().then(
() => {
this.props.onFinished(true);
},
(err) => {
this.setState({ emailBusy: false });
let underlyingError = err;
if (err instanceof UserFriendlyError) {
underlyingError = err.cause;
}
if (underlyingError instanceof MatrixError && underlyingError.errcode === "M_THREEPID_AUTH_FAILED") {
const message =
_t("settings|general|error_email_verification") +
" " +
_t("auth|set_email|verification_pending_description");
Modal.createDialog(QuestionDialog, {
title: _t("auth|set_email|verification_pending_title"),
description: message,
button: _t("action|continue"),
onFinished: this.onEmailDialogFinished,
});
} else {
logger.error("Unable to verify email address: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_email_verification"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
}
},
);
}
public render(): React.ReactNode {
const emailInput = this.state.emailBusy ? (
<Spinner />
) : (
<EditableText
initialValue={this.state.emailAddress}
className="mx_SetEmailDialog_email_input"
placeholder={_t("common|email_address")}
placeholderClassName="mx_SetEmailDialog_email_input_placeholder"
blurToCancel={false}
onValueChanged={this.onEmailAddressChanged}
/>
);
return (
<BaseDialog
className="mx_SetEmailDialog"
onFinished={this.onCancelled}
title={this.props.title}
contentId="mx_Dialog_content"
>
<div className="mx_Dialog_content">
<p id="mx_Dialog_content">{_t("auth|set_email|description")}</p>
{emailInput}
</div>
<div className="mx_Dialog_buttons">
<input
className="mx_Dialog_primary"
type="submit"
value={_t("action|continue")}
onClick={this.onSubmit}
/>
<input type="submit" value={_t("action|skip")} onClick={this.onCancelled} />
</div>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,235 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2018 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.
*/
import * as React from "react";
import { Room, RoomMember, MatrixEvent, User } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import QRCode from "../elements/QRCode";
import { RoomPermalinkCreator, makeUserPermalink } from "../../../utils/permalinks/Permalinks";
import { selectText } from "../../../utils/strings";
import StyledCheckbox from "../elements/StyledCheckbox";
import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import BaseDialog from "./BaseDialog";
import CopyableText from "../elements/CopyableText";
import { XOR } from "../../../@types/common";
const socials = [
{
name: "Facebook",
img: require("../../../../res/img/social/facebook.png"),
url: (url: String) => `https://www.facebook.com/sharer/sharer.php?u=${url}`,
},
{
name: "Twitter",
img: require("../../../../res/img/social/twitter-2.png"),
url: (url: string) => `https://twitter.com/home?status=${url}`,
},
/* // icon missing
name: 'Google Plus',
img: 'img/social/',
url: (url) => `https://plus.google.com/share?url=${url}`,
},*/ {
name: "LinkedIn",
img: require("../../../../res/img/social/linkedin.png"),
url: (url: string) => `https://www.linkedin.com/shareArticle?mini=true&url=${url}`,
},
{
name: "Reddit",
img: require("../../../../res/img/social/reddit.png"),
url: (url: string) => `https://www.reddit.com/submit?url=${url}`,
},
{
name: "email",
img: require("../../../../res/img/social/email-1.png"),
url: (url: string) => `mailto:?body=${url}`,
},
];
interface BaseProps {
/**
* A function that is called when the dialog is dismissed
*/
onFinished(): void;
/**
* An optional string to use as the dialog title.
* If not provided, an appropriate title for the target type will be used.
*/
customTitle?: string;
/**
* An optional string to use as the dialog subtitle
*/
subtitle?: string;
}
interface Props extends BaseProps {
/**
* The target to link to.
* This can be a Room, User, RoomMember, or MatrixEvent or an already computed URL.
* A <u>matrix.to</u> link will be generated out of it if it's not already a url.
*/
target: Room | User | RoomMember | URL;
permalinkCreator?: RoomPermalinkCreator;
}
interface EventProps extends BaseProps {
target: MatrixEvent;
permalinkCreator: RoomPermalinkCreator;
}
interface IState {
linkSpecificEvent: boolean;
permalinkCreator: RoomPermalinkCreator | null;
}
export default class ShareDialog extends React.PureComponent<XOR<Props, EventProps>, IState> {
public constructor(props: XOR<Props, EventProps>) {
super(props);
let permalinkCreator: RoomPermalinkCreator | null = null;
if (props.target instanceof Room) {
permalinkCreator = new RoomPermalinkCreator(props.target);
permalinkCreator.load();
}
this.state = {
// MatrixEvent defaults to share linkSpecificEvent
linkSpecificEvent: this.props.target instanceof MatrixEvent,
permalinkCreator,
};
}
public static onLinkClick(e: React.MouseEvent): void {
e.preventDefault();
selectText(e.currentTarget);
}
private onLinkSpecificEventCheckboxClick = (): void => {
this.setState({
linkSpecificEvent: !this.state.linkSpecificEvent,
});
};
private getUrl(): string {
if (this.props.target instanceof URL) {
return this.props.target.toString();
} else if (this.props.target instanceof Room) {
if (this.state.linkSpecificEvent) {
const events = this.props.target.getLiveTimeline().getEvents();
return this.state.permalinkCreator!.forEvent(events[events.length - 1].getId()!);
} else {
return this.state.permalinkCreator!.forShareableRoom();
}
} else if (this.props.target instanceof User || this.props.target instanceof RoomMember) {
return makeUserPermalink(this.props.target.userId);
} else if (this.state.linkSpecificEvent) {
return this.props.permalinkCreator!.forEvent(this.props.target.getId()!);
} else {
return this.props.permalinkCreator!.forShareableRoom();
}
}
public render(): React.ReactNode {
let title: string | undefined;
let checkbox: JSX.Element | undefined;
if (this.props.target instanceof URL) {
title = this.props.customTitle ?? _t("share|title_link");
} else if (this.props.target instanceof Room) {
title = this.props.customTitle ?? _t("share|title_room");
const events = this.props.target.getLiveTimeline().getEvents();
if (events.length > 0) {
checkbox = (
<div>
<StyledCheckbox
checked={this.state.linkSpecificEvent}
onChange={this.onLinkSpecificEventCheckboxClick}
>
{_t("share|permalink_most_recent")}
</StyledCheckbox>
</div>
);
}
} else if (this.props.target instanceof User || this.props.target instanceof RoomMember) {
title = this.props.customTitle ?? _t("share|title_user");
} else if (this.props.target instanceof MatrixEvent) {
title = this.props.customTitle ?? _t("share|title_message");
checkbox = (
<div>
<StyledCheckbox
checked={this.state.linkSpecificEvent}
onChange={this.onLinkSpecificEventCheckboxClick}
>
{_t("share|permalink_message")}
</StyledCheckbox>
</div>
);
}
const matrixToUrl = this.getUrl();
const encodedUrl = encodeURIComponent(matrixToUrl);
const showQrCode = SettingsStore.getValue(UIFeature.ShareQRCode);
const showSocials = SettingsStore.getValue(UIFeature.ShareSocial);
let qrSocialSection;
if (showQrCode || showSocials) {
qrSocialSection = (
<>
<hr />
<div className="mx_ShareDialog_split">
{showQrCode && (
<div className="mx_ShareDialog_qrcode_container">
<QRCode data={matrixToUrl} width={256} />
</div>
)}
{showSocials && (
<div className="mx_ShareDialog_social_container">
{socials.map((social) => (
<a
rel="noreferrer noopener"
target="_blank"
key={social.name}
title={social.name}
href={social.url(encodedUrl)}
className="mx_ShareDialog_social_icon"
>
<img src={social.img} alt={social.name} height={64} width={64} />
</a>
))}
</div>
)}
</div>
</>
);
}
return (
<BaseDialog
title={title}
className="mx_ShareDialog"
contentId="mx_Dialog_content"
onFinished={this.props.onFinished}
>
{this.props.subtitle && <p>{this.props.subtitle}</p>}
<div className="mx_ShareDialog_content">
<CopyableText getTextToCopy={() => matrixToUrl}>
<a title={_t("share|link_title")} href={matrixToUrl} onClick={ShareDialog.onLinkClick}>
{matrixToUrl}
</a>
</CopyableText>
{checkbox}
{qrSocialSection}
</div>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,71 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { _t } from "../../../languageHandler";
import { Command, CommandCategories, Commands } from "../../../SlashCommands";
import InfoDialog from "./InfoDialog";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
interface IProps {
onFinished(): void;
}
const SlashCommandHelpDialog: React.FC<IProps> = ({ onFinished }) => {
const categories: Record<string, Command[]> = {};
Commands.forEach((cmd) => {
if (!cmd.isEnabled(MatrixClientPeg.get())) return;
if (!categories[cmd.category]) {
categories[cmd.category] = [];
}
categories[cmd.category].push(cmd);
});
const body = Object.values(CommandCategories)
.filter((c) => categories[c])
.map((category) => {
const rows = [
<tr key={"_category_" + category} className="mx_SlashCommandHelpDialog_headerRow">
<td colSpan={3}>
<h2>{_t(category)}</h2>
</td>
</tr>,
];
categories[category].forEach((cmd) => {
rows.push(
<tr key={cmd.command}>
<td>
<strong>{cmd.getCommand()}</strong>
</td>
<td>{cmd.args}</td>
<td>{_t(cmd.description)}</td>
</tr>,
);
});
return rows;
});
return (
<InfoDialog
className="mx_SlashCommandHelpDialog"
title={_t("slash_command|help_dialog_title")}
description={
<table>
<tbody>{body}</tbody>
</table>
}
hasCloseButton={true}
onFinished={onFinished}
/>
);
};
export default SlashCommandHelpDialog;

View file

@ -0,0 +1,90 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ChangeEvent } from "react";
import { Room } from "matrix-js-sdk/src/matrix";
import { _t, _td } from "../../../languageHandler";
import BaseDialog from "../dialogs/BaseDialog";
import TabbedView, { Tab } from "../../structures/TabbedView";
import StyledCheckbox from "../elements/StyledCheckbox";
import { useSettingValue } from "../../../hooks/useSettings";
import SettingsStore from "../../../settings/SettingsStore";
import { SettingLevel } from "../../../settings/SettingLevel";
import RoomName from "../elements/RoomName";
import { SpacePreferenceTab } from "../../../dispatcher/payloads/OpenSpacePreferencesPayload";
import { NonEmptyArray } from "../../../@types/common";
import SettingsTab from "../settings/tabs/SettingsTab";
import { SettingsSection } from "../settings/shared/SettingsSection";
import SettingsSubsection, { SettingsSubsectionText } from "../settings/shared/SettingsSubsection";
interface IProps {
space: Room;
onFinished(): void;
}
const SpacePreferencesAppearanceTab: React.FC<Pick<IProps, "space">> = ({ space }) => {
const showPeople = useSettingValue("Spaces.showPeopleInSpace", space.roomId);
return (
<SettingsTab>
<SettingsSection heading={_t("space|preferences|sections_section")}>
<SettingsSubsection>
<StyledCheckbox
checked={!!showPeople}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
SettingsStore.setValue(
"Spaces.showPeopleInSpace",
space.roomId,
SettingLevel.ROOM_ACCOUNT,
!showPeople,
);
}}
>
{_t("common|people")}
</StyledCheckbox>
<SettingsSubsectionText>
{_t("space|preferences|show_people_in_space", {
spaceName: space.name,
})}
</SettingsSubsectionText>
</SettingsSubsection>
</SettingsSection>
</SettingsTab>
);
};
const SpacePreferencesDialog: React.FC<IProps> = ({ space, onFinished }) => {
const tabs: NonEmptyArray<Tab<SpacePreferenceTab>> = [
new Tab(
SpacePreferenceTab.Appearance,
_td("common|appearance"),
"mx_SpacePreferencesDialog_appearanceIcon",
<SpacePreferencesAppearanceTab space={space} />,
),
];
return (
<BaseDialog
className="mx_SpacePreferencesDialog"
hasCancel
onFinished={onFinished}
title={_t("common|preferences")}
fixedWidth={false}
>
<h4>
<RoomName room={space} />
</h4>
<div className="mx_SettingsDialog_content">
<TabbedView tabs={tabs} activeTabId={SpacePreferenceTab.Appearance} onChange={() => {}} />
</div>
</BaseDialog>
);
};
export default SpacePreferencesDialog;

View file

@ -0,0 +1,94 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useMemo } from "react";
import { Room, MatrixClient } from "matrix-js-sdk/src/matrix";
import { _t, _td } from "../../../languageHandler";
import BaseDialog from "./BaseDialog";
import defaultDispatcher from "../../../dispatcher/dispatcher";
import { useDispatcher } from "../../../hooks/useDispatcher";
import TabbedView, { Tab } from "../../structures/TabbedView";
import SpaceSettingsGeneralTab from "../spaces/SpaceSettingsGeneralTab";
import SpaceSettingsVisibilityTab from "../spaces/SpaceSettingsVisibilityTab";
import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import AdvancedRoomSettingsTab from "../settings/tabs/room/AdvancedRoomSettingsTab";
import RolesRoomSettingsTab from "../settings/tabs/room/RolesRoomSettingsTab";
import { Action } from "../../../dispatcher/actions";
import { NonEmptyArray } from "../../../@types/common";
export enum SpaceSettingsTab {
General = "SPACE_GENERAL_TAB",
Visibility = "SPACE_VISIBILITY_TAB",
Roles = "SPACE_ROLES_TAB",
Advanced = "SPACE_ADVANCED_TAB",
}
interface IProps {
matrixClient: MatrixClient;
space: Room;
onFinished(): void;
}
const SpaceSettingsDialog: React.FC<IProps> = ({ matrixClient: cli, space, onFinished }) => {
useDispatcher(defaultDispatcher, (payload) => {
if (payload.action === Action.AfterLeaveRoom && payload.room_id === space.roomId) {
onFinished();
}
});
const tabs = useMemo(() => {
return [
new Tab(
SpaceSettingsTab.General,
_td("common|general"),
"mx_SpaceSettingsDialog_generalIcon",
<SpaceSettingsGeneralTab matrixClient={cli} space={space} />,
),
new Tab(
SpaceSettingsTab.Visibility,
_td("room_settings|visibility|title"),
"mx_SpaceSettingsDialog_visibilityIcon",
<SpaceSettingsVisibilityTab matrixClient={cli} space={space} closeSettingsFn={onFinished} />,
),
new Tab(
SpaceSettingsTab.Roles,
_td("room_settings|permissions|title"),
"mx_RoomSettingsDialog_rolesIcon",
<RolesRoomSettingsTab room={space} />,
),
SettingsStore.getValue(UIFeature.AdvancedSettings)
? new Tab(
SpaceSettingsTab.Advanced,
_td("common|advanced"),
"mx_RoomSettingsDialog_warningIcon",
<AdvancedRoomSettingsTab room={space} closeSettingsFn={onFinished} />,
)
: null,
].filter(Boolean) as NonEmptyArray<Tab<SpaceSettingsTab>>;
}, [cli, space, onFinished]);
const [activeTabId, setActiveTabId] = React.useState(SpaceSettingsTab.General);
return (
<BaseDialog
title={_t("space_settings|title", { spaceName: space.name || _t("common|unnamed_space") })}
className="mx_SpaceSettingsDialog"
contentId="mx_SpaceSettingsDialog"
onFinished={onFinished}
fixedWidth={false}
>
<div className="mx_SpaceSettingsDialog_content" id="mx_SpaceSettingsDialog">
<TabbedView tabs={tabs} activeTabId={activeTabId} onChange={setActiveTabId} />
</div>
</BaseDialog>
);
};
export default SpaceSettingsDialog;

View file

@ -0,0 +1,71 @@
/*
Copyright 2019-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.
*/
import React from "react";
import SdkConfig from "../../../SdkConfig";
import Modal from "../../../Modal";
import { _t } from "../../../languageHandler";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import BugReportDialog from "./BugReportDialog";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
interface IProps {
onFinished(signOut?: boolean): void;
}
export default class StorageEvictedDialog extends React.Component<IProps> {
private sendBugReport = (ev: ButtonEvent): void => {
ev.preventDefault();
Modal.createDialog(BugReportDialog, {});
};
private onSignOutClick = (): void => {
this.props.onFinished(true);
};
public render(): React.ReactNode {
let logRequest;
if (SdkConfig.get().bug_report_endpoint_url) {
logRequest = _t(
"bug_reporting|log_request",
{},
{
a: (text) => (
<AccessibleButton kind="link_inline" onClick={this.sendBugReport}>
{text}
</AccessibleButton>
),
},
);
}
return (
<BaseDialog
className="mx_ErrorDialog"
onFinished={this.props.onFinished}
title={_t("error|storage_evicted_title")}
contentId="mx_Dialog_content"
hasCancel={false}
>
<div className="mx_Dialog_content" id="mx_Dialog_content">
<p>{_t("error|storage_evicted_description_1")}</p>
<p>
{_t("error|storage_evicted_description_2")} {logRequest}
</p>
</div>
<DialogButtons
primaryButton={_t("action|sign_out")}
onPrimaryButtonClick={this.onSignOutClick}
focus={true}
hasCancel={false}
/>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,217 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { SERVICE_TYPES } from "matrix-js-sdk/src/matrix";
import { _t, pickBestLanguage } from "../../../languageHandler";
import DialogButtons from "../elements/DialogButtons";
import BaseDialog from "./BaseDialog";
import { ServicePolicyPair } from "../../../Terms";
import ExternalLink from "../elements/ExternalLink";
import { parseUrl } from "../../../utils/UrlUtils";
interface ITermsCheckboxProps {
onChange: (url: string, checked: boolean) => void;
url: string;
checked: boolean;
}
class TermsCheckbox extends React.PureComponent<ITermsCheckboxProps> {
private onChange = (ev: React.FormEvent<HTMLInputElement>): void => {
this.props.onChange(this.props.url, ev.currentTarget.checked);
};
public render(): React.ReactNode {
return <input type="checkbox" onChange={this.onChange} checked={this.props.checked} />;
}
}
interface ITermsDialogProps {
/**
* Array of [Service, policies] pairs, where policies is the response from the
* /terms endpoint for that service
*/
policiesAndServicePairs: ServicePolicyPair[];
/**
* urls that the user has already agreed to
*/
agreedUrls: string[];
/**
* Called with:
* * success {bool} True if the user accepted any douments, false if cancelled
* * agreedUrls {string[]} List of agreed URLs
*/
onFinished: (success: boolean, agreedUrls?: string[]) => void;
}
interface IState {
agreedUrls: any;
}
export default class TermsDialog extends React.PureComponent<ITermsDialogProps, IState> {
public constructor(props: ITermsDialogProps) {
super(props);
this.state = {
// url -> boolean
agreedUrls: {},
};
for (const url of props.agreedUrls) {
this.state.agreedUrls[url] = true;
}
}
private onCancelClick = (): void => {
this.props.onFinished(false);
};
private onNextClick = (): void => {
this.props.onFinished(
true,
Object.keys(this.state.agreedUrls).filter((url) => this.state.agreedUrls[url]),
);
};
private nameForServiceType(serviceType: SERVICE_TYPES, host: string): JSX.Element {
switch (serviceType) {
case SERVICE_TYPES.IS:
return (
<div>
{_t("common|identity_server")}
<br />({host})
</div>
);
case SERVICE_TYPES.IM:
return (
<div>
{_t("common|integration_manager")}
<br />({host})
</div>
);
}
}
private summaryForServiceType(serviceType: SERVICE_TYPES): JSX.Element {
switch (serviceType) {
case SERVICE_TYPES.IS:
return (
<div>
{_t("terms|summary_identity_server_1")}
<br />
{_t("terms|summary_identity_server_2")}
</div>
);
case SERVICE_TYPES.IM:
return <div>{_t("terms|integration_manager")}</div>;
}
}
private onTermsCheckboxChange = (url: string, checked: boolean): void => {
this.setState({
agreedUrls: Object.assign({}, this.state.agreedUrls, { [url]: checked }),
});
};
public render(): React.ReactNode {
const rows: JSX.Element[] = [];
for (const policiesAndService of this.props.policiesAndServicePairs) {
const parsedBaseUrl = parseUrl(policiesAndService.service.baseUrl);
const policyValues = Object.values(policiesAndService.policies);
for (let i = 0; i < policyValues.length; ++i) {
const termDoc = policyValues[i];
const termsLang = pickBestLanguage(Object.keys(termDoc).filter((k) => k !== "version"));
let serviceName: JSX.Element | undefined;
let summary: JSX.Element | undefined;
if (i === 0) {
serviceName = this.nameForServiceType(policiesAndService.service.serviceType, parsedBaseUrl.host);
summary = this.summaryForServiceType(policiesAndService.service.serviceType);
}
rows.push(
<tr key={termDoc[termsLang].url}>
<td className="mx_TermsDialog_service">{serviceName}</td>
<td className="mx_TermsDialog_summary">{summary}</td>
<td>
<ExternalLink rel="noreferrer noopener" target="_blank" href={termDoc[termsLang].url}>
{termDoc[termsLang].name}
</ExternalLink>
</td>
<td>
<TermsCheckbox
url={termDoc[termsLang].url}
onChange={this.onTermsCheckboxChange}
checked={Boolean(this.state.agreedUrls[termDoc[termsLang].url])}
/>
</td>
</tr>,
);
}
}
// if all the documents for at least one service have been checked, we can enable
// the submit button
let enableSubmit = false;
for (const policiesAndService of this.props.policiesAndServicePairs) {
let docsAgreedForService = 0;
for (const terms of Object.values(policiesAndService.policies)) {
let docAgreed = false;
for (const lang of Object.keys(terms)) {
if (lang === "version") continue;
if (this.state.agreedUrls[terms[lang].url]) {
docAgreed = true;
break;
}
}
if (docAgreed) {
++docsAgreedForService;
}
}
if (docsAgreedForService === Object.keys(policiesAndService.policies).length) {
enableSubmit = true;
break;
}
}
return (
<BaseDialog
fixedWidth={false}
onFinished={this.onCancelClick}
title={_t("terms|tos")}
contentId="mx_Dialog_content"
hasCancel={false}
>
<div id="mx_Dialog_content">
<p>{_t("terms|intro")}</p>
<table className="mx_TermsDialog_termsTable">
<tbody>
<tr className="mx_TermsDialog_termsTableHeader">
<th>{_t("terms|column_service")}</th>
<th>{_t("terms|column_summary")}</th>
<th>{_t("terms|column_document")}</th>
<th>{_t("action|accept")}</th>
</tr>
{rows}
</tbody>
</table>
</div>
<DialogButtons
primaryButton={_t("action|next")}
hasCancel={true}
onCancel={this.onCancelClick}
onPrimaryButtonClick={this.onNextClick}
primaryDisabled={!enableSubmit}
/>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,139 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2015, 2016 OpenMarket Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ChangeEvent, createRef } from "react";
import Field from "../elements/Field";
import { _t, _td, TranslationKey } from "../../../languageHandler";
import { IFieldState, IValidationResult } from "../elements/Validation";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
interface IProps {
title: string;
description: React.ReactNode;
value: string;
placeholder?: string;
button?: string;
busyMessage: TranslationKey;
focus: boolean;
hasCancel: boolean;
validator?: (fieldState: IFieldState) => Promise<IValidationResult>; // result of withValidation
fixedWidth?: boolean;
onFinished(ok?: false, text?: void): void;
onFinished(ok: true, text: string): void;
}
interface IState {
value: string;
busy: boolean;
valid: boolean;
}
export default class TextInputDialog extends React.Component<IProps, IState> {
private field = createRef<Field>();
public static defaultProps: Partial<IProps> = {
title: "",
value: "",
description: "",
busyMessage: _td("common|loading"),
focus: true,
hasCancel: true,
};
public constructor(props: IProps) {
super(props);
this.state = {
value: this.props.value,
busy: false,
valid: false,
};
}
public componentDidMount(): void {
if (this.props.focus) {
// Set the cursor at the end of the text input
// this._field.current.value = this.props.value;
this.field.current?.focus();
}
}
private onOk = async (ev: React.FormEvent): Promise<void> => {
ev.preventDefault();
if (!this.field.current) return;
if (this.props.validator) {
this.setState({ busy: true });
await this.field.current.validate({ allowEmpty: false });
if (!this.field.current.state.valid) {
this.field.current.focus();
this.field.current.validate({ allowEmpty: false, focused: true });
this.setState({ busy: false });
return;
}
}
this.props.onFinished(true, this.state.value);
};
private onCancel = (): void => {
this.props.onFinished(false);
};
private onChange = (ev: ChangeEvent<HTMLInputElement>): void => {
this.setState({
value: ev.target.value,
});
};
private onValidate = async (fieldState: IFieldState): Promise<IValidationResult> => {
const result = await this.props.validator!(fieldState);
this.setState({
valid: !!result.valid,
});
return result;
};
public render(): React.ReactNode {
return (
<BaseDialog
className="mx_TextInputDialog"
onFinished={this.props.onFinished}
title={this.props.title}
fixedWidth={this.props.fixedWidth}
>
<form onSubmit={this.onOk}>
<div className="mx_Dialog_content">
<div className="mx_TextInputDialog_label">
<label htmlFor="textinput"> {this.props.description} </label>
</div>
<div>
<Field
className="mx_TextInputDialog_input"
ref={this.field}
type="text"
label={this.props.placeholder}
value={this.state.value}
onChange={this.onChange}
onValidate={this.props.validator ? this.onValidate : undefined}
/>
</div>
</div>
</form>
<DialogButtons
primaryButton={this.state.busy ? _t(this.props.busyMessage) : this.props.button}
disabled={this.state.busy}
onPrimaryButtonClick={this.onOk}
onCancel={this.onCancel}
hasCancel={this.props.hasCancel}
/>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,72 @@
/*
* Copyright 2024 New Vector Ltd.
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
* Please see LICENSE files in the repository root for full details.
*/
import React, { JSX } from "react";
import { Button, Text } from "@vector-im/compound-web";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import BaseDialog from "../dialogs/BaseDialog";
import { _t } from "../../../languageHandler";
import PinningUtils from "../../../utils/PinningUtils.ts";
import PosthogTrackers from "../../../PosthogTrackers.ts";
/**
* Properties for {@link UnpinAllDialog}.
*/
interface UnpinAllDialogProps {
/*
* The matrix client to use.
*/
matrixClient: MatrixClient;
/*
* The room ID to unpin all events in.
*/
roomId: string;
/*
* Callback for when the dialog is closed.
*/
onFinished: () => void;
}
/**
* A dialog that asks the user to confirm unpinning all events in a room.
*/
export function UnpinAllDialog({ matrixClient, roomId, onFinished }: UnpinAllDialogProps): JSX.Element {
return (
<BaseDialog
hasCancel={true}
title={_t("right_panel|pinned_messages|unpin_all|title")}
titleClass="mx_UnpinAllDialog_title"
className="mx_UnpinAllDialog"
onFinished={onFinished}
fixedWidth={false}
>
<Text as="span">{_t("right_panel|pinned_messages|unpin_all|content")}</Text>
<div className="mx_UnpinAllDialog_buttons">
<Button
destructive={true}
onClick={async () => {
try {
await PinningUtils.unpinAllEvents(matrixClient, roomId);
PosthogTrackers.trackPinUnpinMessage("Unpin", "UnpinAll");
} catch (e) {
logger.error("Failed to unpin all events:", e);
}
onFinished();
}}
>
{_t("action|continue")}
</Button>
<Button kind="tertiary" onClick={onFinished}>
{_t("action|cancel")}
</Button>
</div>
</BaseDialog>
);
}

View file

@ -0,0 +1,73 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019, 2020 , 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { User } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import E2EIcon, { E2EState } from "../rooms/E2EIcon";
import AccessibleButton from "../elements/AccessibleButton";
import BaseDialog from "./BaseDialog";
import { IDevice } from "../right_panel/UserInfo";
interface IProps {
user: User;
device: IDevice;
onFinished(mode?: "legacy" | "sas" | false): void;
}
const UntrustedDeviceDialog: React.FC<IProps> = ({ device, user, onFinished }) => {
let askToVerifyText: string;
let newSessionText: string;
if (MatrixClientPeg.safeGet().getUserId() === user.userId) {
newSessionText = _t("encryption|udd|own_new_session_text");
askToVerifyText = _t("encryption|udd|own_ask_verify_text");
} else {
newSessionText = _t("encryption|udd|other_new_session_text", {
name: user.displayName,
userId: user.userId,
});
askToVerifyText = _t("encryption|udd|other_ask_verify_text");
}
return (
<BaseDialog
onFinished={onFinished}
className="mx_UntrustedDeviceDialog"
title={
<>
<E2EIcon status={E2EState.Warning} isUser size={24} hideTooltip={true} />
{_t("encryption|udd|title")}
</>
}
>
<div className="mx_Dialog_content" id="mx_Dialog_content">
<p>{newSessionText}</p>
<p>
{device.displayName} ({device.deviceId})
</p>
<p>{askToVerifyText}</p>
</div>
<div className="mx_Dialog_buttons">
<AccessibleButton kind="primary_outline" onClick={() => onFinished("legacy")}>
{_t("encryption|udd|manual_verification_button")}
</AccessibleButton>
<AccessibleButton kind="primary_outline" onClick={() => onFinished("sas")}>
{_t("encryption|udd|interactive_verification_button")}
</AccessibleButton>
<AccessibleButton kind="primary" onClick={() => onFinished(false)}>
{_t("action|done")}
</AccessibleButton>
</div>
</BaseDialog>
);
};
export default UntrustedDeviceDialog;

View file

@ -0,0 +1,128 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2021 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { Icon as FileIcon } from "../../../../res/img/feather-customised/files.svg";
import { _t } from "../../../languageHandler";
import { getBlobSafeMimeType } from "../../../utils/blobs";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import { fileSize } from "../../../utils/FileUtils";
interface IProps {
file: File;
currentIndex: number;
totalFiles: number;
onFinished: (uploadConfirmed: boolean, uploadAll?: boolean) => void;
}
export default class UploadConfirmDialog extends React.Component<IProps> {
private readonly objectUrl: string;
private readonly mimeType: string;
public static defaultProps: Partial<IProps> = {
totalFiles: 1,
currentIndex: 0,
};
public constructor(props: IProps) {
super(props);
// Create a fresh `Blob` for previewing (even though `File` already is
// one) so we can adjust the MIME type if needed.
this.mimeType = getBlobSafeMimeType(props.file.type);
const blob = new Blob([props.file], { type: this.mimeType });
this.objectUrl = URL.createObjectURL(blob);
}
public componentWillUnmount(): void {
if (this.objectUrl) URL.revokeObjectURL(this.objectUrl);
}
private onCancelClick = (): void => {
this.props.onFinished(false);
};
private onUploadClick = (): void => {
this.props.onFinished(true);
};
private onUploadAllClick = (): void => {
this.props.onFinished(true, true);
};
public render(): React.ReactNode {
let title: string;
if (this.props.totalFiles > 1 && this.props.currentIndex !== undefined) {
title = _t("upload_file|title_progress", {
current: this.props.currentIndex + 1,
total: this.props.totalFiles,
});
} else {
title = _t("upload_file|title");
}
const fileId = `mx-uploadconfirmdialog-${this.props.file.name}`;
let preview: JSX.Element | undefined;
let placeholder: JSX.Element | undefined;
if (this.mimeType.startsWith("image/")) {
preview = (
<img className="mx_UploadConfirmDialog_imagePreview" src={this.objectUrl} aria-labelledby={fileId} />
);
} else if (this.mimeType.startsWith("video/")) {
preview = (
<video
className="mx_UploadConfirmDialog_imagePreview"
src={this.objectUrl}
playsInline
controls={false}
/>
);
} else {
placeholder = <FileIcon className="mx_UploadConfirmDialog_fileIcon" height={18} width={18} />;
}
let uploadAllButton: JSX.Element | undefined;
if (this.props.currentIndex + 1 < this.props.totalFiles) {
uploadAllButton = <button onClick={this.onUploadAllClick}>{_t("upload_file|upload_all_button")}</button>;
}
return (
<BaseDialog
className="mx_UploadConfirmDialog"
fixedWidth={false}
onFinished={this.onCancelClick}
title={title}
contentId="mx_Dialog_content"
>
<div id="mx_Dialog_content">
<div className="mx_UploadConfirmDialog_previewOuter">
<div className="mx_UploadConfirmDialog_previewInner">
{preview && <div>{preview}</div>}
<div id={fileId}>
{placeholder}
{this.props.file.name} ({fileSize(this.props.file.size)})
</div>
</div>
</div>
</div>
<DialogButtons
primaryButton={_t("action|upload")}
hasCancel={false}
onPrimaryButtonClick={this.onUploadClick}
focus={true}
>
{uploadAllButton}
</DialogButtons>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,117 @@
/*
Copyright 2019-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.
*/
import React from "react";
import { _t } from "../../../languageHandler";
import ContentMessages from "../../../ContentMessages";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import { fileSize } from "../../../utils/FileUtils";
interface IProps {
badFiles: File[];
totalFiles: number;
contentMessages: ContentMessages;
onFinished(upload?: boolean): void;
}
/*
* Tells the user about files we know cannot be uploaded before we even try uploading
* them. This is named fairly generically but the only thing we check right now is
* the size of the file.
*/
export default class UploadFailureDialog extends React.Component<IProps> {
private onCancelClick = (): void => {
this.props.onFinished(false);
};
private onUploadClick = (): void => {
this.props.onFinished(true);
};
public render(): React.ReactNode {
let message;
let preview;
let buttons;
if (this.props.totalFiles === 1 && this.props.badFiles.length === 1) {
message = _t(
"upload_file|error_file_too_large",
{
limit: fileSize(this.props.contentMessages.getUploadLimit()!),
sizeOfThisFile: fileSize(this.props.badFiles[0].size),
},
{
b: (sub) => <strong>{sub}</strong>,
},
);
buttons = (
<DialogButtons
primaryButton={_t("action|ok")}
hasCancel={false}
onPrimaryButtonClick={this.onCancelClick}
focus={true}
/>
);
} else if (this.props.totalFiles === this.props.badFiles.length) {
message = _t(
"upload_file|error_files_too_large",
{
limit: fileSize(this.props.contentMessages.getUploadLimit()!),
},
{
b: (sub) => <strong>{sub}</strong>,
},
);
buttons = (
<DialogButtons
primaryButton={_t("action|ok")}
hasCancel={false}
onPrimaryButtonClick={this.onCancelClick}
focus={true}
/>
);
} else {
message = _t(
"upload_file|error_some_files_too_large",
{
limit: fileSize(this.props.contentMessages.getUploadLimit()!),
},
{
b: (sub) => <strong>{sub}</strong>,
},
);
const howManyOthers = this.props.totalFiles - this.props.badFiles.length;
buttons = (
<DialogButtons
primaryButton={_t("upload_file|upload_n_others_button", { count: howManyOthers })}
onPrimaryButtonClick={this.onUploadClick}
hasCancel={true}
cancelButton={_t("upload_file|cancel_all_button")}
onCancel={this.onCancelClick}
focus={true}
/>
);
}
return (
<BaseDialog
className="mx_UploadFailureDialog"
onFinished={this.onCancelClick}
title={_t("upload_file|error_title")}
contentId="mx_Dialog_content"
>
<div id="mx_Dialog_content">
{message}
{preview}
</div>
{buttons}
</BaseDialog>
);
}
}

View file

@ -0,0 +1,249 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019-2024 The Matrix.org Foundation C.I.C.
Copyright 2019 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.
*/
import { Toast } from "@vector-im/compound-web";
import React, { useState } from "react";
import UserProfileIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-profile";
import DevicesIcon from "@vector-im/compound-design-tokens/assets/web/icons/devices";
import VisibilityOnIcon from "@vector-im/compound-design-tokens/assets/web/icons/visibility-on";
import NotificationsIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications";
import PreferencesIcon from "@vector-im/compound-design-tokens/assets/web/icons/preferences";
import KeyboardIcon from "@vector-im/compound-design-tokens/assets/web/icons/keyboard";
import SidebarIcon from "@vector-im/compound-design-tokens/assets/web/icons/sidebar";
import MicOnIcon from "@vector-im/compound-design-tokens/assets/web/icons/mic-on";
import LockIcon from "@vector-im/compound-design-tokens/assets/web/icons/lock";
import LabsIcon from "@vector-im/compound-design-tokens/assets/web/icons/labs";
import BlockIcon from "@vector-im/compound-design-tokens/assets/web/icons/block";
import HelpIcon from "@vector-im/compound-design-tokens/assets/web/icons/help";
import TabbedView, { Tab, useActiveTabWithDefault } from "../../structures/TabbedView";
import { _t, _td } from "../../../languageHandler";
import AccountUserSettingsTab from "../settings/tabs/user/AccountUserSettingsTab";
import SettingsStore from "../../../settings/SettingsStore";
import LabsUserSettingsTab, { showLabsFlags } from "../settings/tabs/user/LabsUserSettingsTab";
import AppearanceUserSettingsTab from "../settings/tabs/user/AppearanceUserSettingsTab";
import SecurityUserSettingsTab from "../settings/tabs/user/SecurityUserSettingsTab";
import NotificationUserSettingsTab from "../settings/tabs/user/NotificationUserSettingsTab";
import PreferencesUserSettingsTab from "../settings/tabs/user/PreferencesUserSettingsTab";
import VoiceUserSettingsTab from "../settings/tabs/user/VoiceUserSettingsTab";
import HelpUserSettingsTab from "../settings/tabs/user/HelpUserSettingsTab";
import MjolnirUserSettingsTab from "../settings/tabs/user/MjolnirUserSettingsTab";
import { UIFeature } from "../../../settings/UIFeature";
import BaseDialog from "./BaseDialog";
import SidebarUserSettingsTab from "../settings/tabs/user/SidebarUserSettingsTab";
import KeyboardUserSettingsTab from "../settings/tabs/user/KeyboardUserSettingsTab";
import SessionManagerTab from "../settings/tabs/user/SessionManagerTab";
import { UserTab } from "./UserTab";
import { NonEmptyArray } from "../../../@types/common";
import { SDKContext, SdkContextClass } from "../../../contexts/SDKContext";
import { useSettingValue } from "../../../hooks/useSettings";
import { ToastContext, useActiveToast } from "../../../contexts/ToastContext";
interface IProps {
initialTabId?: UserTab;
showMsc4108QrCode?: boolean;
sdkContext: SdkContextClass;
onFinished(): void;
}
function titleForTabID(tabId: UserTab): React.ReactNode {
const subs = {
strong: (sub: string) => <span className="mx_UserSettingsDialog_title_strong">{sub}</span>,
};
switch (tabId) {
case UserTab.Account:
return _t("settings|account|dialog_title", undefined, subs);
case UserTab.SessionManager:
return _t("settings|sessions|dialog_title", undefined, subs);
case UserTab.Appearance:
return _t("settings|appearance|dialog_title", undefined, subs);
case UserTab.Notifications:
return _t("settings|notifications|dialog_title", undefined, subs);
case UserTab.Preferences:
return _t("settings|preferences|dialog_title", undefined, subs);
case UserTab.Keyboard:
return _t("settings|keyboard|dialog_title", undefined, subs);
case UserTab.Sidebar:
return _t("settings|sidebar|dialog_title", undefined, subs);
case UserTab.Voice:
return _t("settings|voip|dialog_title", undefined, subs);
case UserTab.Security:
return _t("settings|security|dialog_title", undefined, subs);
case UserTab.Labs:
return _t("settings|labs|dialog_title", undefined, subs);
case UserTab.Mjolnir:
return _t("settings|labs_mjolnir|dialog_title", undefined, subs);
case UserTab.Help:
return _t("setting|help_about|dialog_title", undefined, subs);
}
}
export default function UserSettingsDialog(props: IProps): JSX.Element {
const voipEnabled = useSettingValue<boolean>(UIFeature.Voip);
const mjolnirEnabled = useSettingValue<boolean>("feature_mjolnir");
// store this prop in state as changing tabs back and forth should clear it
const [showMsc4108QrCode, setShowMsc4108QrCode] = useState(props.showMsc4108QrCode);
const getTabs = (): NonEmptyArray<Tab<UserTab>> => {
const tabs: Tab<UserTab>[] = [];
tabs.push(
new Tab(
UserTab.Account,
_td("settings|account|title"),
<UserProfileIcon />,
<AccountUserSettingsTab closeSettingsFn={props.onFinished} />,
"UserSettingsGeneral",
),
);
tabs.push(
new Tab(
UserTab.SessionManager,
_td("settings|sessions|title"),
<DevicesIcon />,
<SessionManagerTab showMsc4108QrCode={showMsc4108QrCode} />,
undefined,
),
);
tabs.push(
new Tab(
UserTab.Appearance,
_td("common|appearance"),
<VisibilityOnIcon />,
<AppearanceUserSettingsTab />,
"UserSettingsAppearance",
),
);
tabs.push(
new Tab(
UserTab.Notifications,
_td("notifications|enable_prompt_toast_title"),
<NotificationsIcon />,
<NotificationUserSettingsTab />,
"UserSettingsNotifications",
),
);
tabs.push(
new Tab(
UserTab.Preferences,
_td("common|preferences"),
<PreferencesIcon />,
<PreferencesUserSettingsTab closeSettingsFn={props.onFinished} />,
"UserSettingsPreferences",
),
);
tabs.push(
new Tab(
UserTab.Keyboard,
_td("settings|keyboard|title"),
<KeyboardIcon />,
<KeyboardUserSettingsTab />,
"UserSettingsKeyboard",
),
);
tabs.push(
new Tab(
UserTab.Sidebar,
_td("settings|sidebar|title"),
<SidebarIcon />,
<SidebarUserSettingsTab />,
"UserSettingsSidebar",
),
);
if (voipEnabled) {
tabs.push(
new Tab(
UserTab.Voice,
_td("settings|voip|title"),
<MicOnIcon />,
<VoiceUserSettingsTab />,
"UserSettingsVoiceVideo",
),
);
}
tabs.push(
new Tab(
UserTab.Security,
_td("room_settings|security|title"),
<LockIcon />,
<SecurityUserSettingsTab closeSettingsFn={props.onFinished} />,
"UserSettingsSecurityPrivacy",
),
);
if (showLabsFlags() || SettingsStore.getFeatureSettingNames().some((k) => SettingsStore.getBetaInfo(k))) {
tabs.push(
new Tab(UserTab.Labs, _td("common|labs"), <LabsIcon />, <LabsUserSettingsTab />, "UserSettingsLabs"),
);
}
if (mjolnirEnabled) {
tabs.push(
new Tab(
UserTab.Mjolnir,
_td("labs_mjolnir|title"),
<BlockIcon />,
<MjolnirUserSettingsTab />,
"UserSettingMjolnir",
),
);
}
tabs.push(
new Tab(
UserTab.Help,
_td("setting|help_about|title"),
<HelpIcon />,
<HelpUserSettingsTab />,
"UserSettingsHelpAbout",
),
);
return tabs as NonEmptyArray<Tab<UserTab>>;
};
const [activeTabId, _setActiveTabId] = useActiveTabWithDefault(getTabs(), UserTab.Account, props.initialTabId);
const setActiveTabId = (tabId: UserTab): void => {
_setActiveTabId(tabId);
// Clear this so switching away from the tab and back to it will not show the QR code again
setShowMsc4108QrCode(false);
};
const [activeToast, toastRack] = useActiveToast();
return (
// XXX: SDKContext is provided within the LoggedInView subtree.
// Modals function outside the MatrixChat React tree, so sdkContext is reprovided here to simulate that.
// The longer term solution is to move our ModalManager into the React tree to inherit contexts properly.
<SDKContext.Provider value={props.sdkContext}>
<ToastContext.Provider value={toastRack}>
<BaseDialog
className="mx_UserSettingsDialog"
hasCancel={true}
onFinished={props.onFinished}
title={titleForTabID(activeTabId)}
titleClass="mx_UserSettingsDialog_title"
>
<div className="mx_SettingsDialog_content">
<TabbedView
tabs={getTabs()}
activeTabId={activeTabId}
screenName="UserSettings"
onChange={setActiveTabId}
responsive={true}
/>
</div>
<div className="mx_SettingsDialog_toastContainer">
{activeToast && <Toast>{activeToast}</Toast>}
</div>
</BaseDialog>
</ToastContext.Provider>
</SDKContext.Provider>
);
}

View file

@ -0,0 +1,22 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
export enum UserTab {
Account = "USER_ACCOUNT_TAB",
Appearance = "USER_APPEARANCE_TAB",
Notifications = "USER_NOTIFICATIONS_TAB",
Preferences = "USER_PREFERENCES_TAB",
Keyboard = "USER_KEYBOARD_TAB",
Sidebar = "USER_SIDEBAR_TAB",
Voice = "USER_VOICE_TAB",
Security = "USER_SECURITY_TAB",
Labs = "USER_LABS_TAB",
Mjolnir = "USER_MJOLNIR_TAB",
Help = "USER_HELP_TAB",
SessionManager = "USER_SESSION_MANAGER_TAB",
}

View file

@ -0,0 +1,69 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { VerificationRequest } from "matrix-js-sdk/src/crypto-api";
import { User } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { _t } from "../../../languageHandler";
import BaseDialog from "./BaseDialog";
import EncryptionPanel from "../right_panel/EncryptionPanel";
interface IProps {
verificationRequest?: VerificationRequest;
verificationRequestPromise?: Promise<VerificationRequest>;
onFinished: () => void;
member?: User;
}
interface IState {
verificationRequest?: VerificationRequest;
}
export default class VerificationRequestDialog extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
verificationRequest: this.props.verificationRequest,
};
this.props.verificationRequestPromise?.then((r) => {
this.setState({ verificationRequest: r });
});
}
public render(): React.ReactNode {
const request = this.state.verificationRequest;
const otherUserId = request?.otherUserId;
const member = this.props.member || (otherUserId ? MatrixClientPeg.safeGet().getUser(otherUserId) : null);
const title = request?.isSelfVerification
? _t("encryption|verification|verification_dialog_title_device")
: _t("encryption|verification|verification_dialog_title_user");
if (!member) return null;
return (
<BaseDialog
className="mx_InfoDialog"
onFinished={this.props.onFinished}
contentId="mx_Dialog_content"
title={title}
hasCancel={true}
>
<EncryptionPanel
layout="dialog"
verificationRequest={this.props.verificationRequest}
verificationRequestPromise={this.props.verificationRequestPromise}
onClose={this.props.onFinished}
member={member}
isRoomEncrypted={false}
/>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,140 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { Capability, isTimelineCapability, Widget, WidgetEventCapability, WidgetKind } from "matrix-widget-api";
import { lexicographicCompare } from "matrix-js-sdk/src/utils";
import BaseDialog from "./BaseDialog";
import { _t } from "../../../languageHandler";
import { objectShallowClone } from "../../../utils/objects";
import StyledCheckbox from "../elements/StyledCheckbox";
import DialogButtons from "../elements/DialogButtons";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { CapabilityText } from "../../../widgets/CapabilityText";
interface IProps {
requestedCapabilities: Set<Capability>;
widget: Widget;
widgetKind: WidgetKind; // TODO: Refactor into the Widget class
onFinished(result?: { approved: Capability[]; remember: boolean }): void;
}
type BooleanStates = Partial<{
[capability in Capability]: boolean;
}>;
interface IState {
booleanStates: BooleanStates;
rememberSelection: boolean;
}
export default class WidgetCapabilitiesPromptDialog extends React.PureComponent<IProps, IState> {
private eventPermissionsMap = new Map<Capability, WidgetEventCapability>();
public constructor(props: IProps) {
super(props);
const parsedEvents = WidgetEventCapability.findEventCapabilities(this.props.requestedCapabilities);
parsedEvents.forEach((e) => this.eventPermissionsMap.set(e.raw, e));
const states: BooleanStates = {};
this.props.requestedCapabilities.forEach((c) => (states[c] = true));
this.state = {
booleanStates: states,
rememberSelection: true,
};
}
private onToggle = (capability: Capability): void => {
const newStates = objectShallowClone(this.state.booleanStates);
newStates[capability] = !newStates[capability];
this.setState({ booleanStates: newStates });
};
private onRememberSelectionChange = (newVal: boolean): void => {
this.setState({ rememberSelection: newVal });
};
private onSubmit = async (): Promise<void> => {
this.closeAndTryRemember(
Object.entries(this.state.booleanStates)
.filter(([_, isSelected]) => isSelected)
.map(([cap]) => cap),
);
};
private onReject = async (): Promise<void> => {
this.closeAndTryRemember([]); // nothing was approved
};
private closeAndTryRemember(approved: Capability[]): void {
this.props.onFinished({ approved, remember: this.state.rememberSelection });
}
public render(): React.ReactNode {
// We specifically order the timeline capabilities down to the bottom. The capability text
// generation cares strongly about this.
const orderedCapabilities = Object.entries(this.state.booleanStates).sort(([capA], [capB]) => {
const isTimelineA = isTimelineCapability(capA);
const isTimelineB = isTimelineCapability(capB);
if (!isTimelineA && !isTimelineB) return lexicographicCompare(capA, capB);
if (isTimelineA && !isTimelineB) return 1;
if (!isTimelineA && isTimelineB) return -1;
if (isTimelineA && isTimelineB) return lexicographicCompare(capA, capB);
return 0;
});
const checkboxRows = orderedCapabilities.map(([cap, isChecked], i) => {
const text = CapabilityText.for(cap, this.props.widgetKind);
const byline = text.byline ? (
<span className="mx_WidgetCapabilitiesPromptDialog_byline">{text.byline}</span>
) : null;
return (
<div className="mx_WidgetCapabilitiesPromptDialog_cap" key={cap + i}>
<StyledCheckbox checked={isChecked} onChange={() => this.onToggle(cap)}>
{text.primary}
</StyledCheckbox>
{byline}
</div>
);
});
return (
<BaseDialog
className="mx_WidgetCapabilitiesPromptDialog"
onFinished={this.props.onFinished}
title={_t("widget|capabilities_dialog|title")}
>
<form onSubmit={this.onSubmit}>
<div className="mx_Dialog_content">
<div className="text-muted">{_t("widget|capabilities_dialog|content_starting_text")}</div>
{checkboxRows}
<DialogButtons
primaryButton={_t("action|approve")}
cancelButton={_t("widget|capabilities_dialog|decline_all_permission")}
onPrimaryButtonClick={this.onSubmit}
onCancel={this.onReject}
additive={
<LabelledToggleSwitch
value={this.state.rememberSelection}
toggleInFront={true}
onChange={this.onRememberSelectionChange}
label={_t("widget|capabilities_dialog|remember_Selection")}
/>
}
/>
</div>
</form>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,99 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C.
Copyright 2019 Travis Ralston
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { Widget, WidgetKind } from "matrix-widget-api";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../languageHandler";
import LabelledToggleSwitch from "../elements/LabelledToggleSwitch";
import { OIDCState } from "../../../stores/widgets/WidgetPermissionStore";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import { SdkContextClass } from "../../../contexts/SDKContext";
interface IProps {
widget: Widget;
widgetKind: WidgetKind;
inRoomId?: string;
onFinished(allowed?: boolean): void;
}
interface IState {
rememberSelection: boolean;
}
export default class WidgetOpenIDPermissionsDialog extends React.PureComponent<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
rememberSelection: false,
};
}
private onAllow = (): void => {
this.onPermissionSelection(true);
};
private onDeny = (): void => {
this.onPermissionSelection(false);
};
private onPermissionSelection(allowed: boolean): void {
if (this.state.rememberSelection) {
logger.log(`Remembering ${this.props.widget.id} as allowed=${allowed} for OpenID`);
SdkContextClass.instance.widgetPermissionStore.setOIDCState(
this.props.widget,
this.props.widgetKind,
this.props.inRoomId,
allowed ? OIDCState.Allowed : OIDCState.Denied,
);
}
this.props.onFinished(allowed);
}
private onRememberSelectionChange = (newVal: boolean): void => {
this.setState({ rememberSelection: newVal });
};
public render(): React.ReactNode {
return (
<BaseDialog
className="mx_WidgetOpenIDPermissionsDialog"
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("widget|open_id_permissions_dialog|title")}
>
<div className="mx_WidgetOpenIDPermissionsDialog_content">
<p>{_t("widget|open_id_permissions_dialog|starting_text")}</p>
<p className="text-muted">
{/* cheap trim to just get the path */}
{this.props.widget.templateUrl.split("?")[0].split("#")[0]}
</p>
</div>
<DialogButtons
primaryButton={_t("action|continue")}
onPrimaryButtonClick={this.onAllow}
onCancel={this.onDeny}
additive={
<LabelledToggleSwitch
value={this.state.rememberSelection}
toggleInFront={true}
onChange={this.onRememberSelectionChange}
label={_t("widget|open_id_permissions_dialog|remember_selection")}
/>
}
/>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,112 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useContext, useMemo, useState } from "react";
import { IContent, MatrixEvent } from "matrix-js-sdk/src/matrix";
import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool";
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import { EventEditor, EventViewer, eventTypeField, IEditorProps, stringify } from "./Event";
import FilteredList from "./FilteredList";
import { _td, TranslationKey } from "../../../../languageHandler";
export const AccountDataEventEditor: React.FC<IEditorProps> = ({ mxEvent, onBack }) => {
const cli = useContext(MatrixClientContext);
const fields = useMemo(() => [eventTypeField(mxEvent?.getType())], [mxEvent]);
const onSend = async ([eventType]: string[], content?: IContent): Promise<void> => {
await cli.setAccountData(eventType, content || {});
};
const defaultContent = mxEvent ? stringify(mxEvent.getContent()) : undefined;
return <EventEditor fieldDefs={fields} defaultContent={defaultContent} onSend={onSend} onBack={onBack} />;
};
export const RoomAccountDataEventEditor: React.FC<IEditorProps> = ({ mxEvent, onBack }) => {
const context = useContext(DevtoolsContext);
const cli = useContext(MatrixClientContext);
const fields = useMemo(() => [eventTypeField(mxEvent?.getType())], [mxEvent]);
const onSend = async ([eventType]: string[], content?: IContent): Promise<void> => {
await cli.setRoomAccountData(context.room.roomId, eventType, content || {});
};
const defaultContent = mxEvent ? stringify(mxEvent.getContent()) : undefined;
return <EventEditor fieldDefs={fields} defaultContent={defaultContent} onSend={onSend} onBack={onBack} />;
};
interface IProps extends IDevtoolsProps {
events: Map<string, MatrixEvent>;
Editor: React.FC<IEditorProps>;
actionLabel: TranslationKey;
}
const BaseAccountDataExplorer: React.FC<IProps> = ({ events, Editor, actionLabel, onBack, setTool }) => {
const [query, setQuery] = useState("");
const [event, setEvent] = useState<MatrixEvent | null>(null);
if (event) {
const onBack = (): void => {
setEvent(null);
};
return <EventViewer mxEvent={event} onBack={onBack} Editor={Editor} />;
}
const onAction = async (): Promise<void> => {
setTool(actionLabel, Editor);
};
return (
<BaseTool onBack={onBack} actionLabel={actionLabel} onAction={onAction}>
<FilteredList query={query} onChange={setQuery}>
{Array.from(events.entries()).map(([eventType, ev]) => {
const onClick = (): void => {
setEvent(ev);
};
return (
<button className="mx_DevTools_button" key={eventType} onClick={onClick}>
{eventType}
</button>
);
})}
</FilteredList>
</BaseTool>
);
};
export const AccountDataExplorer: React.FC<IDevtoolsProps> = ({ onBack, setTool }) => {
const cli = useContext(MatrixClientContext);
return (
<BaseAccountDataExplorer
events={cli.store.accountData}
Editor={AccountDataEventEditor}
actionLabel={_td("devtools|send_custom_account_data_event")}
onBack={onBack}
setTool={setTool}
/>
);
};
export const RoomAccountDataExplorer: React.FC<IDevtoolsProps> = ({ onBack, setTool }) => {
const context = useContext(DevtoolsContext);
return (
<BaseAccountDataExplorer
events={context.room.accountData}
Editor={RoomAccountDataEventEditor}
actionLabel={_td("devtools|send_custom_room_account_data_event")}
onBack={onBack}
setTool={setTool}
/>
);
};

View file

@ -0,0 +1,86 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { createContext, ReactNode, useState } from "react";
import { Room } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { _t, TranslationKey } from "../../../../languageHandler";
import { XOR } from "../../../../@types/common";
import { Tool } from "../DevtoolsDialog";
export interface IDevtoolsProps {
onBack(): void;
setTool(label: TranslationKey, tool: Tool): void;
}
interface IMinProps extends Pick<IDevtoolsProps, "onBack"> {
className?: string;
children?: ReactNode;
extraButton?: ReactNode;
}
interface IProps extends IMinProps {
actionLabel: TranslationKey;
onAction(): Promise<string | void>;
}
const BaseTool: React.FC<XOR<IMinProps, IProps>> = ({
className,
actionLabel,
onBack,
onAction,
children,
extraButton,
}) => {
const [message, setMessage] = useState<string | null>(null);
const onBackClick = (): void => {
if (message) {
setMessage(null);
} else {
onBack();
}
};
let actionButton: ReactNode = null;
if (message) {
children = message;
} else if (onAction && actionLabel) {
const onActionClick = (): void => {
onAction().then((msg) => {
if (typeof msg === "string") {
setMessage(msg);
}
});
};
actionButton = <button onClick={onActionClick}>{_t(actionLabel)}</button>;
}
return (
<>
<div className={classNames("mx_DevTools_content", className)}>{children}</div>
<div className="mx_Dialog_buttons">
{extraButton}
<button onClick={onBackClick}>{_t("action|back")}</button>
{actionButton}
</div>
</>
);
};
export default BaseTool;
interface IContext {
room: Room;
threadRootId?: string | null;
}
export const DevtoolsContext = createContext<IContext>({} as IContext);

View file

@ -0,0 +1,209 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ChangeEvent, ReactNode, useContext, useMemo, useRef, useState } from "react";
import { IContent, MatrixEvent, TimelineEvents } from "matrix-js-sdk/src/matrix";
import { _t, _td, TranslationKey } from "../../../../languageHandler";
import Field from "../../elements/Field";
import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool";
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import withValidation from "../../elements/Validation";
import SyntaxHighlight from "../../elements/SyntaxHighlight";
export const stringify = (object: object): string => {
return JSON.stringify(object, null, 2);
};
interface IEventEditorProps extends Pick<IDevtoolsProps, "onBack"> {
fieldDefs: IFieldDef[]; // immutable
defaultContent?: string;
onSend(fields: string[], content: IContent): Promise<unknown>;
}
interface IFieldDef {
id: string;
label: TranslationKey;
default?: string;
}
export const eventTypeField = (defaultValue?: string): IFieldDef => ({
id: "eventType",
label: _td("devtools|event_type"),
default: defaultValue,
});
export const stateKeyField = (defaultValue?: string): IFieldDef => ({
id: "stateKey",
label: _td("devtools|state_key"),
default: defaultValue,
});
const validateEventContent = withValidation<any, Error | undefined>({
async deriveData({ value }) {
try {
JSON.parse(value!);
} catch (e) {
return e as Error;
}
return undefined;
},
rules: [
{
key: "validJson",
test: ({ value }, error) => {
if (!value) return true;
return !error;
},
invalid: (error) => _t("devtools|invalid_json") + " " + error,
},
],
});
export const EventEditor: React.FC<IEventEditorProps> = ({ fieldDefs, defaultContent = "{\n\n}", onSend, onBack }) => {
const [fieldData, setFieldData] = useState<string[]>(fieldDefs.map((def) => def.default ?? ""));
const [content, setContent] = useState<string>(defaultContent);
const contentField = useRef<Field>(null);
const fields = fieldDefs.map((def, i) => (
<Field
key={def.id}
id={def.id}
label={_t(def.label)}
size={42}
autoFocus={defaultContent === undefined && i === 0}
type="text"
autoComplete="on"
value={fieldData[i]}
onChange={(ev: ChangeEvent<HTMLInputElement>) =>
setFieldData((data) => {
data[i] = ev.target.value;
return [...data];
})
}
/>
));
const onAction = async (): Promise<string | undefined> => {
const valid = contentField.current ? await contentField.current.validate({}) : false;
if (!valid) {
contentField.current?.focus();
contentField.current?.validate({ focused: true });
return;
}
try {
const json = JSON.parse(content);
await onSend(fieldData, json);
} catch (e) {
return _t("devtools|failed_to_send") + (e instanceof Error ? ` (${e.message})` : "");
}
return _t("devtools|event_sent");
};
return (
<BaseTool actionLabel={_td("forward|send_label")} onAction={onAction} onBack={onBack}>
<div className="mx_DevTools_eventTypeStateKeyGroup">{fields}</div>
<Field
id="evContent"
label={_t("devtools|event_content")}
type="text"
className="mx_DevTools_textarea"
autoComplete="off"
value={content}
onChange={(ev) => setContent(ev.target.value)}
element="textarea"
onValidate={validateEventContent}
ref={contentField}
autoFocus={!!defaultContent}
/>
</BaseTool>
);
};
export interface IEditorProps extends Pick<IDevtoolsProps, "onBack"> {
mxEvent?: MatrixEvent;
}
interface IViewerProps extends Required<IEditorProps> {
Editor: React.FC<IEditorProps>;
extraButton?: ReactNode;
}
export const EventViewer: React.FC<IViewerProps> = ({ mxEvent, onBack, Editor, extraButton }) => {
const [editing, setEditing] = useState(false);
if (editing) {
const onBack = (): void => {
setEditing(false);
};
return <Editor mxEvent={mxEvent} onBack={onBack} />;
}
const onAction = async (): Promise<void> => {
setEditing(true);
};
return (
<BaseTool onBack={onBack} actionLabel={_td("action|edit")} onAction={onAction} extraButton={extraButton}>
<SyntaxHighlight language="json">{stringify(mxEvent.event)}</SyntaxHighlight>
</BaseTool>
);
};
// returns the id of the initial message, not the id of the previous edit
const getBaseEventId = (baseEvent: MatrixEvent): string => {
// show the replacing event, not the original, if it is an edit
const mxEvent = baseEvent.replacingEvent() ?? baseEvent;
return mxEvent.getWireContent()["m.relates_to"]?.event_id ?? baseEvent.getId()!;
};
export const TimelineEventEditor: React.FC<IEditorProps> = ({ mxEvent, onBack }) => {
const context = useContext(DevtoolsContext);
const cli = useContext(MatrixClientContext);
const fields = useMemo(() => [eventTypeField(mxEvent?.getType())], [mxEvent]);
const onSend = ([eventType]: string[], content: TimelineEvents[keyof TimelineEvents]): Promise<unknown> => {
return cli.sendEvent(context.room.roomId, eventType as keyof TimelineEvents, content);
};
let defaultContent: string | undefined;
if (mxEvent) {
const originalContent = mxEvent.getContent();
// prefill an edit-message event, keep only the `body` and `msgtype` fields of originalContent
const bodyToStartFrom = originalContent["m.new_content"]?.body ?? originalContent.body; // prefill the last edit body, to start editing from there
const newContent = {
"body": ` * ${bodyToStartFrom}`,
"msgtype": originalContent.msgtype,
"m.new_content": {
body: bodyToStartFrom,
msgtype: originalContent.msgtype,
},
"m.relates_to": {
rel_type: "m.replace",
event_id: getBaseEventId(mxEvent),
},
};
defaultContent = stringify(newContent);
} else if (context.threadRootId) {
defaultContent = stringify({
"m.relates_to": {
rel_type: "m.thread",
event_id: context.threadRootId,
},
});
}
return <EventEditor fieldDefs={fields} defaultContent={defaultContent} onSend={onSend} onBack={onBack} />;
};

View file

@ -0,0 +1,88 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ChangeEvent, useEffect, useState } from "react";
import { _t } from "../../../../languageHandler";
import Field from "../../elements/Field";
import TruncatedList from "../../elements/TruncatedList";
const INITIAL_LOAD_TILES = 20;
const LOAD_TILES_STEP_SIZE = 50;
interface IProps {
children: React.ReactElement[];
query: string;
onChange(value: string): void;
}
const FilteredList: React.FC<IProps> = ({ children, query, onChange }) => {
const [truncateAt, setTruncateAt] = useState<number>(INITIAL_LOAD_TILES);
const [filteredChildren, setFilteredChildren] = useState<React.ReactElement[]>(children);
useEffect(() => {
let filteredChildren = children;
if (query) {
const lcQuery = query.toLowerCase();
filteredChildren = children.filter((child) => child.key?.toString().toLowerCase().includes(lcQuery));
}
setFilteredChildren(filteredChildren);
setTruncateAt(INITIAL_LOAD_TILES);
}, [children, query]);
const getChildren = (start: number, end: number): React.ReactElement[] => {
return filteredChildren.slice(start, end);
};
const getChildCount = (): number => {
return filteredChildren.length;
};
const createOverflowElement = (overflowCount: number, totalCount: number): JSX.Element => {
const showMore = (): void => {
setTruncateAt((num) => num + LOAD_TILES_STEP_SIZE);
};
return (
<button className="mx_DevTools_button" onClick={showMore}>
{_t("common|and_n_others", { count: overflowCount })}
</button>
);
};
return (
<>
<Field
label={_t("common|filter_results")}
autoFocus={true}
size={64}
type="text"
autoComplete="off"
value={query}
onChange={(ev: ChangeEvent<HTMLInputElement>) => onChange(ev.target.value)}
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
// force re-render so that autoFocus is applied when this component is re-used
key={children?.[0]?.key ?? ""}
/>
{filteredChildren.length < 1 ? (
_t("common|no_results_found")
) : (
<TruncatedList
getChildren={getChildren}
getChildCount={getChildCount}
truncateAt={truncateAt}
createOverflowElement={createOverflowElement}
/>
)}
</>
);
};
export default FilteredList;

View file

@ -0,0 +1,220 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { NotificationCountType, Room, Thread, ReceiptType } from "matrix-js-sdk/src/matrix";
import React, { useContext } from "react";
import { ReadReceipt } from "matrix-js-sdk/src/models/read-receipt";
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import { useNotificationState } from "../../../../hooks/useRoomNotificationState";
import { _t, _td } from "../../../../languageHandler";
import { determineUnreadState } from "../../../../RoomNotifs";
import { humanReadableNotificationLevel } from "../../../../stores/notifications/NotificationLevel";
import { doesRoomOrThreadHaveUnreadMessages } from "../../../../Unread";
import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool";
function UserReadUpTo({ target }: { target: ReadReceipt<any, any> }): JSX.Element {
const cli = useContext(MatrixClientContext);
const userId = cli.getSafeUserId();
const hasPrivate = !!target.getReadReceiptForUserId(userId, false, ReceiptType.ReadPrivate);
return (
<>
<li>
{_t("devtools|user_read_up_to")}
<strong>{target.getReadReceiptForUserId(userId)?.eventId ?? _t("devtools|no_receipt_found")}</strong>
</li>
<li>
{_t("devtools|user_read_up_to_ignore_synthetic")}
<strong>
{target.getReadReceiptForUserId(userId, true)?.eventId ?? _t("devtools|no_receipt_found")}
</strong>
</li>
{hasPrivate && (
<>
<li>
{_t("devtools|user_read_up_to_private")}
<strong>
{target.getReadReceiptForUserId(userId, false, ReceiptType.ReadPrivate)?.eventId ??
_t("devtools|no_receipt_found")}
</strong>
</li>
<li>
{_t("devtools|user_read_up_to_private_ignore_synthetic")}
<strong>
{target.getReadReceiptForUserId(userId, true, ReceiptType.ReadPrivate)?.eventId ??
_t("devtools|no_receipt_found")}
</strong>
</li>
</>
)}
</>
);
}
export default function RoomNotifications({ onBack }: IDevtoolsProps): JSX.Element {
const { room } = useContext(DevtoolsContext);
const cli = useContext(MatrixClientContext);
const { level, count } = determineUnreadState(room, undefined, false);
const [notificationState] = useNotificationState(room);
return (
<BaseTool onBack={onBack}>
<section>
<h2>{_t("devtools|room_status")}</h2>
<ul>
<li>
{_t(
"devtools|room_unread_status_count",
{
status: humanReadableNotificationLevel(level),
count,
},
{
strong: (sub) => <strong>{sub}</strong>,
},
)}
</li>
<li>
{_t(
"devtools|notification_state",
{
notificationState,
},
{
strong: (sub) => <strong>{sub}</strong>,
},
)}
</li>
<li>
{_t(
cli.isRoomEncrypted(room.roomId!)
? _td("devtools|room_encrypted")
: _td("devtools|room_not_encrypted"),
{},
{
strong: (sub) => <strong>{sub}</strong>,
},
)}
</li>
</ul>
</section>
<section>
<h2>{_t("devtools|main_timeline")}</h2>
<ul>
<li>
{_t("devtools|room_notifications_total")}{" "}
{room.getRoomUnreadNotificationCount(NotificationCountType.Total)}
</li>
<li>
{_t("devtools|room_notifications_highlight")}{" "}
{room.getRoomUnreadNotificationCount(NotificationCountType.Highlight)}
</li>
<li>
{_t("devtools|room_notifications_dot")} {doesRoomOrThreadHaveUnreadMessages(room) + ""}
</li>
{roomHasUnread(room) && (
<>
<UserReadUpTo target={room} />
<li>
{_t("devtools|room_notifications_last_event")}
<ul>
<li>
{_t("devtools|id")}{" "}
<strong>{room.timeline[room.timeline.length - 1].getId()}</strong>
</li>
<li>
{_t("devtools|room_notifications_type")}{" "}
<strong>{room.timeline[room.timeline.length - 1].getType()}</strong>
</li>
<li>
{_t("devtools|room_notifications_sender")}{" "}
<strong>{room.timeline[room.timeline.length - 1].getSender()}</strong>
</li>
</ul>
</li>
</>
)}
</ul>
</section>
<section>
<h2>{_t("devtools|threads_timeline")}</h2>
<ul>
{room
.getThreads()
.filter((thread) => threadHasUnread(thread))
.map((thread) => (
<li key={thread.id}>
{_t("devtools|room_notifications_thread_id")} {thread.id}
<ul>
<li>
{_t("devtools|room_notifications_total")}
<strong>
{room.getThreadUnreadNotificationCount(
thread.id,
NotificationCountType.Total,
)}
</strong>
</li>
<li>
{_t("devtools|room_notifications_highlight")}
<strong>
{room.getThreadUnreadNotificationCount(
thread.id,
NotificationCountType.Highlight,
)}
</strong>
</li>
<li>
{_t("devtools|room_notifications_dot")}{" "}
<strong>{doesRoomOrThreadHaveUnreadMessages(thread) + ""}</strong>
</li>
<UserReadUpTo target={thread} />
<li>
{_t("devtools|room_notifications_last_event")}
<ul>
<li>
{_t("devtools|id")} <strong>{thread.lastReply()?.getId()}</strong>
</li>
<li>
{_t("devtools|room_notifications_type")}{" "}
<strong>{thread.lastReply()?.getType()}</strong>
</li>
<li>
{_t("devtools|room_notifications_sender")}{" "}
<strong>{thread.lastReply()?.getSender()}</strong>
</li>
</ul>
</li>
</ul>
</li>
))}
</ul>
</section>
</BaseTool>
);
}
function threadHasUnread(thread: Thread): boolean {
const total = thread.room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total);
const highlight = thread.room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Highlight);
const dot = doesRoomOrThreadHaveUnreadMessages(thread);
return total > 0 || highlight > 0 || dot;
}
function roomHasUnread(room: Room): boolean {
const total = room.getRoomUnreadNotificationCount(NotificationCountType.Total);
const highlight = room.getRoomUnreadNotificationCount(NotificationCountType.Highlight);
const dot = doesRoomOrThreadHaveUnreadMessages(room);
return total > 0 || highlight > 0 || dot;
}

View file

@ -0,0 +1,187 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useContext, useEffect, useMemo, useState } from "react";
import { IContent, MatrixEvent } from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { _t, _td } from "../../../../languageHandler";
import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool";
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import { EventEditor, EventViewer, eventTypeField, stateKeyField, IEditorProps, stringify } from "./Event";
import FilteredList from "./FilteredList";
import Spinner from "../../elements/Spinner";
import SyntaxHighlight from "../../elements/SyntaxHighlight";
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
export const StateEventEditor: React.FC<IEditorProps> = ({ mxEvent, onBack }) => {
const context = useContext(DevtoolsContext);
const cli = useContext(MatrixClientContext);
const fields = useMemo(
() => [eventTypeField(mxEvent?.getType()), stateKeyField(mxEvent?.getStateKey())],
[mxEvent],
);
const onSend = async ([eventType, stateKey]: string[], content: IContent): Promise<void> => {
await cli.sendStateEvent(context.room.roomId, eventType as any, content, stateKey);
};
const defaultContent = mxEvent ? stringify(mxEvent.getContent()) : undefined;
return <EventEditor fieldDefs={fields} defaultContent={defaultContent} onSend={onSend} onBack={onBack} />;
};
interface StateEventButtonProps {
label: string;
onClick(): void;
}
const RoomStateHistory: React.FC<{
mxEvent: MatrixEvent;
onBack(): void;
}> = ({ mxEvent, onBack }) => {
const cli = useContext(MatrixClientContext);
const events = useAsyncMemo(
async () => {
const events = [mxEvent.event];
while (!!events[0].unsigned?.replaces_state) {
try {
events.unshift(await cli.fetchRoomEvent(mxEvent.getRoomId()!, events[0].unsigned.replaces_state));
} catch (e) {
events.unshift({
event_id: events[0].unsigned.replaces_state,
unsigned: {
error: e instanceof Error ? e.message : String(e),
},
});
}
}
return events;
},
[cli, mxEvent],
null,
);
let body = <Spinner />;
if (events !== null) {
body = (
<>
{events.map((ev) => (
<SyntaxHighlight language="json" key={ev.event_id}>
{stringify(ev)}
</SyntaxHighlight>
))}
</>
);
}
return <BaseTool onBack={onBack}>{body}</BaseTool>;
};
const StateEventButton: React.FC<StateEventButtonProps> = ({ label, onClick }) => {
const trimmed = label.trim();
let content = label;
if (!trimmed) {
content = label.length > 0 ? _t("devtools|spaces", { count: label.length }) : _t("devtools|empty_string");
}
return (
<button
className={classNames("mx_DevTools_button", {
mx_DevTools_RoomStateExplorer_button_hasSpaces: trimmed.length !== label.length,
mx_DevTools_RoomStateExplorer_button_emptyString: !trimmed,
})}
onClick={onClick}
>
{content}
</button>
);
};
interface IEventTypeProps extends Pick<IDevtoolsProps, "onBack"> {
eventType: string;
}
const RoomStateExplorerEventType: React.FC<IEventTypeProps> = ({ eventType, onBack }) => {
const context = useContext(DevtoolsContext);
const [query, setQuery] = useState("");
const [event, setEvent] = useState<MatrixEvent | null>(null);
const [history, setHistory] = useState(false);
const events = context.room.currentState.events.get(eventType)!;
useEffect(() => {
if (events.size === 1 && events.has("")) {
setEvent(events.get("")!);
} else {
setEvent(null);
}
}, [events]);
if (event && history) {
const _onBack = (): void => {
setHistory(false);
};
return <RoomStateHistory mxEvent={event} onBack={_onBack} />;
}
if (event) {
const _onBack = (): void => {
if (events?.size === 1 && events.has("")) {
onBack();
} else {
setEvent(null);
}
};
const onHistoryClick = (): void => {
setHistory(true);
};
const extraButton = <button onClick={onHistoryClick}>{_t("devtools|see_history")}</button>;
return <EventViewer mxEvent={event} onBack={_onBack} Editor={StateEventEditor} extraButton={extraButton} />;
}
return (
<BaseTool onBack={onBack}>
<FilteredList query={query} onChange={setQuery}>
{Array.from(events.entries()).map(([stateKey, ev]) => (
<StateEventButton key={stateKey} label={stateKey} onClick={() => setEvent(ev)} />
))}
</FilteredList>
</BaseTool>
);
};
export const RoomStateExplorer: React.FC<IDevtoolsProps> = ({ onBack, setTool }) => {
const context = useContext(DevtoolsContext);
const [query, setQuery] = useState("");
const [eventType, setEventType] = useState<string | null>(null);
const events = context.room.currentState.events;
if (eventType !== null) {
const onBack = (): void => {
setEventType(null);
};
return <RoomStateExplorerEventType eventType={eventType} onBack={onBack} />;
}
const onAction = async (): Promise<void> => {
setTool(_td("devtools|send_custom_state_event"), StateEventEditor);
};
return (
<BaseTool onBack={onBack} actionLabel={_td("devtools|send_custom_state_event")} onAction={onAction}>
<FilteredList query={query} onChange={setQuery}>
{Array.from(events.keys()).map((eventType) => (
<StateEventButton key={eventType} label={eventType} onClick={() => setEventType(eventType)} />
))}
</FilteredList>
</BaseTool>
);
};

View file

@ -0,0 +1,95 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useContext } from "react";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import BaseTool, { IDevtoolsProps } from "./BaseTool";
import { _t } from "../../../../languageHandler";
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import Spinner from "../../elements/Spinner";
import SyntaxHighlight from "../../elements/SyntaxHighlight";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
const FAILED_TO_LOAD = Symbol("failed-to-load");
interface IServerWellKnown {
server: {
name: string;
version: string;
};
}
export async function getServerVersionFromFederationApi(client: MatrixClient): Promise<IServerWellKnown> {
let baseUrl = client.getHomeserverUrl();
try {
const hsName = MatrixClientPeg.safeGet().getDomain();
// We don't use the js-sdk Autodiscovery module here as it only support client well-known, not server ones.
const response = await fetch(`https://${hsName}/.well-known/matrix/server`);
const json = await response.json();
if (json["m.server"]) {
baseUrl = `https://${json["m.server"]}`;
}
} catch (e) {
console.warn(e);
}
const response = await fetch(`${baseUrl}/_matrix/federation/v1/version`);
return response.json();
}
const ServerInfo: React.FC<IDevtoolsProps> = ({ onBack }) => {
const cli = useContext(MatrixClientContext);
const capabilities = useAsyncMemo(() => cli.fetchCapabilities().catch(() => FAILED_TO_LOAD), [cli]);
const clientVersions = useAsyncMemo(() => cli.getVersions().catch(() => FAILED_TO_LOAD), [cli]);
const serverVersions = useAsyncMemo(async (): Promise<IServerWellKnown | symbol> => {
try {
return await getServerVersionFromFederationApi(cli);
} catch (e) {
console.warn(e);
}
return FAILED_TO_LOAD;
}, [cli]);
let body: JSX.Element;
if (!capabilities || !clientVersions || !serverVersions) {
body = <Spinner />;
} else {
body = (
<>
<h4>{_t("common|capabilities")}</h4>
{capabilities !== FAILED_TO_LOAD ? (
<SyntaxHighlight language="json" children={JSON.stringify(capabilities, null, 4)} />
) : (
<div>{_t("devtools|failed_to_load")}</div>
)}
<h4>{_t("devtools|client_versions")}</h4>
{clientVersions !== FAILED_TO_LOAD ? (
<SyntaxHighlight language="json" children={JSON.stringify(clientVersions, null, 4)} />
) : (
<div>{_t("devtools|failed_to_load")}</div>
)}
<h4>{_t("devtools|server_versions")}</h4>
{serverVersions !== FAILED_TO_LOAD ? (
<SyntaxHighlight language="json" children={JSON.stringify(serverVersions, null, 4)} />
) : (
<div>{_t("devtools|failed_to_load")}</div>
)}
</>
);
}
return <BaseTool onBack={onBack}>{body}</BaseTool>;
};
export default ServerInfo;

View file

@ -0,0 +1,52 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useContext, useMemo } from "react";
import { EventType } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool";
import { _t } from "../../../../languageHandler";
const ServersInRoom: React.FC<IDevtoolsProps> = ({ onBack }) => {
const context = useContext(DevtoolsContext);
const servers = useMemo<Record<string, number>>(() => {
const servers: Record<string, number> = {};
context.room.currentState.getStateEvents(EventType.RoomMember).forEach((ev) => {
if (ev.getContent().membership !== KnownMembership.Join) return; // only count joined users
const server = ev.getSender()!.split(":")[1];
servers[server] = (servers[server] ?? 0) + 1;
});
return servers;
}, [context.room]);
return (
<BaseTool onBack={onBack}>
<table>
<thead>
<tr>
<th>{_t("common|server")}</th>
<th>{_t("devtools|number_of_users")}</th>
</tr>
</thead>
<tbody>
{Object.entries(servers).map(([server, numUsers]) => (
<tr key={server}>
<td>{server}</td>
<td>{numUsers}</td>
</tr>
))}
</tbody>
</table>
</BaseTool>
);
};
export default ServersInRoom;

View file

@ -0,0 +1,320 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2018-2023 The Matrix.org Foundation C.I.C.
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { ChangeEvent, useContext, useMemo, useState } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { _t, _td } from "../../../../languageHandler";
import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool";
import AccessibleButton from "../../elements/AccessibleButton";
import SettingsStore, { LEVEL_ORDER } from "../../../../settings/SettingsStore";
import { SettingLevel } from "../../../../settings/SettingLevel";
import { SETTINGS } from "../../../../settings/Settings";
import Field from "../../elements/Field";
const SettingExplorer: React.FC<IDevtoolsProps> = ({ onBack }) => {
const [setting, setSetting] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
if (setting && editing) {
const onBack = (): void => {
setEditing(false);
};
return <EditSetting setting={setting} onBack={onBack} />;
} else if (setting) {
const onBack = (): void => {
setSetting(null);
};
const onEdit = async (): Promise<void> => {
setEditing(true);
};
return <ViewSetting setting={setting} onBack={onBack} onEdit={onEdit} />;
} else {
const onView = (setting: string): void => {
setSetting(setting);
};
const onEdit = (setting: string): void => {
setSetting(setting);
setEditing(true);
};
return <SettingsList onBack={onBack} onView={onView} onEdit={onEdit} />;
}
};
export default SettingExplorer;
interface ICanEditLevelFieldProps {
setting: string;
level: SettingLevel;
roomId?: string;
}
const CanEditLevelField: React.FC<ICanEditLevelFieldProps> = ({ setting, roomId, level }) => {
const canEdit = SettingsStore.canSetValue(setting, roomId ?? null, level);
const className = canEdit ? "mx_DevTools_SettingsExplorer_mutable" : "mx_DevTools_SettingsExplorer_immutable";
return (
<td className={className}>
<code>{canEdit.toString()}</code>
</td>
);
};
function renderExplicitSettingValues(setting: string, roomId?: string): string {
const vals: Record<string, number | null> = {};
for (const level of LEVEL_ORDER) {
try {
vals[level] = SettingsStore.getValueAt(level, setting, roomId, true, true);
if (vals[level] === undefined) {
vals[level] = null;
}
} catch (e) {
logger.warn(e);
}
}
return JSON.stringify(vals, null, 4);
}
interface IEditSettingProps extends Pick<IDevtoolsProps, "onBack"> {
setting: string;
}
const EditSetting: React.FC<IEditSettingProps> = ({ setting, onBack }) => {
const context = useContext(DevtoolsContext);
const [explicitValue, setExplicitValue] = useState(renderExplicitSettingValues(setting));
const [explicitRoomValue, setExplicitRoomValue] = useState(
renderExplicitSettingValues(setting, context.room.roomId),
);
const onSave = async (): Promise<string | undefined> => {
try {
const parsedExplicit = JSON.parse(explicitValue);
const parsedExplicitRoom = JSON.parse(explicitRoomValue);
for (const level of Object.keys(parsedExplicit)) {
logger.log(`[Devtools] Setting value of ${setting} at ${level} from user input`);
try {
const val = parsedExplicit[level];
await SettingsStore.setValue(setting, null, level as SettingLevel, val);
} catch (e) {
logger.warn(e);
}
}
const roomId = context.room.roomId;
for (const level of Object.keys(parsedExplicit)) {
logger.log(`[Devtools] Setting value of ${setting} at ${level} in ${roomId} from user input`);
try {
const val = parsedExplicitRoom[level];
await SettingsStore.setValue(setting, roomId, level as SettingLevel, val);
} catch (e) {
logger.warn(e);
}
}
onBack();
} catch (e) {
return _t("devtools|failed_to_save") + (e instanceof Error ? ` (${e.message})` : "");
}
};
return (
<BaseTool onBack={onBack} actionLabel={_td("devtools|save_setting_values")} onAction={onSave}>
<h3>
{_t("devtools|setting_colon")} <code>{setting}</code>
</h3>
<div className="mx_DevTools_SettingsExplorer_warning">
<strong>{_t("devtools|caution_colon")}</strong> {_t("devtools|use_at_own_risk")}
</div>
<div>
{_t("devtools|setting_definition")}
<pre>
<code>{JSON.stringify(SETTINGS[setting], null, 4)}</code>
</pre>
</div>
<div>
<table>
<thead>
<tr>
<th>{_t("devtools|level")}</th>
<th>{_t("devtools|settable_global")}</th>
<th>{_t("devtools|settable_room")}</th>
</tr>
</thead>
<tbody>
{LEVEL_ORDER.map((lvl) => (
<tr key={lvl}>
<td>
<code>{lvl}</code>
</td>
<CanEditLevelField setting={setting} level={lvl} />
<CanEditLevelField setting={setting} roomId={context.room.roomId} level={lvl} />
</tr>
))}
</tbody>
</table>
</div>
<div>
<Field
id="valExpl"
label={_t("devtools|values_explicit")}
type="text"
className="mx_DevTools_textarea"
element="textarea"
autoComplete="off"
value={explicitValue}
onChange={(e) => setExplicitValue(e.target.value)}
/>
</div>
<div>
<Field
id="valExpl"
label={_t("devtools|values_explicit_room")}
type="text"
className="mx_DevTools_textarea"
element="textarea"
autoComplete="off"
value={explicitRoomValue}
onChange={(e) => setExplicitRoomValue(e.target.value)}
/>
</div>
</BaseTool>
);
};
interface IViewSettingProps extends Pick<IDevtoolsProps, "onBack"> {
setting: string;
onEdit(): Promise<void>;
}
const ViewSetting: React.FC<IViewSettingProps> = ({ setting, onEdit, onBack }) => {
const context = useContext(DevtoolsContext);
return (
<BaseTool onBack={onBack} actionLabel={_td("devtools|edit_values")} onAction={onEdit}>
<h3>
{_t("devtools|setting_colon")} <code>{setting}</code>
</h3>
<div>
{_t("devtools|setting_definition")}
<pre>
<code>{JSON.stringify(SETTINGS[setting], null, 4)}</code>
</pre>
</div>
<div>
{_t("devtools|value_colon")}&nbsp;
<code>{renderSettingValue(SettingsStore.getValue(setting))}</code>
</div>
<div>
{_t("devtools|value_this_room_colon")}&nbsp;
<code>{renderSettingValue(SettingsStore.getValue(setting, context.room.roomId))}</code>
</div>
<div>
{_t("devtools|values_explicit_colon")}
<pre>
<code>{renderExplicitSettingValues(setting)}</code>
</pre>
</div>
<div>
{_t("devtools|values_explicit_this_room_colon")}
<pre>
<code>{renderExplicitSettingValues(setting, context.room.roomId)}</code>
</pre>
</div>
</BaseTool>
);
};
function renderSettingValue(val: any): string {
// Note: we don't .toString() a string because we want JSON.stringify to inject quotes for us
const toStringTypes = ["boolean", "number"];
if (toStringTypes.includes(typeof val)) {
return val.toString();
} else {
return JSON.stringify(val);
}
}
interface ISettingsListProps extends Pick<IDevtoolsProps, "onBack"> {
onView(setting: string): void;
onEdit(setting: string): void;
}
const SettingsList: React.FC<ISettingsListProps> = ({ onBack, onView, onEdit }) => {
const context = useContext(DevtoolsContext);
const [query, setQuery] = useState("");
const allSettings = useMemo(() => {
let allSettings = Object.keys(SETTINGS);
if (query) {
const lcQuery = query.toLowerCase();
allSettings = allSettings.filter((setting) => setting.toLowerCase().includes(lcQuery));
}
return allSettings;
}, [query]);
return (
<BaseTool onBack={onBack} className="mx_DevTools_SettingsExplorer">
<Field
label={_t("common|filter_results")}
autoFocus={true}
size={64}
type="text"
autoComplete="off"
value={query}
onChange={(ev: ChangeEvent<HTMLInputElement>) => setQuery(ev.target.value)}
className="mx_TextInputDialog_input mx_DevTools_RoomStateExplorer_query"
/>
<table>
<thead>
<tr>
<th>{_t("devtools|setting_id")}</th>
<th>{_t("devtools|value")}</th>
<th>{_t("devtools|value_in_this_room")}</th>
</tr>
</thead>
<tbody>
{allSettings.map((i) => (
<tr key={i}>
<td>
<AccessibleButton
kind="link_inline"
className="mx_DevTools_SettingsExplorer_setting"
onClick={() => onView(i)}
>
<code>{i}</code>
</AccessibleButton>
<AccessibleButton
alt={_t("devtools|edit_setting")}
onClick={() => onEdit(i)}
className="mx_DevTools_SettingsExplorer_edit"
>
</AccessibleButton>
</td>
<td>
<code>{renderSettingValue(SettingsStore.getValue(i))}</code>
</td>
<td>
<code>{renderSettingValue(SettingsStore.getValue(i, context.room.roomId))}</code>
</td>
</tr>
))}
</tbody>
</table>
</BaseTool>
);
};

View file

@ -0,0 +1,98 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useContext, useEffect, useState } from "react";
import {
VerificationPhase as Phase,
VerificationRequest,
VerificationRequestEvent,
} from "matrix-js-sdk/src/crypto-api";
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { useTypedEventEmitter, useTypedEventEmitterState } from "../../../../hooks/useEventEmitter";
import { _t, _td, TranslationKey } from "../../../../languageHandler";
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool";
import { Tool } from "../DevtoolsDialog";
const PHASE_MAP: Record<Phase, TranslationKey> = {
[Phase.Unsent]: _td("common|unsent"),
[Phase.Requested]: _td("devtools|phase_requested"),
[Phase.Ready]: _td("devtools|phase_ready"),
[Phase.Done]: _td("action|done"),
[Phase.Started]: _td("devtools|phase_started"),
[Phase.Cancelled]: _td("devtools|phase_cancelled"),
};
const VerificationRequestExplorer: React.FC<{
txnId: string;
request: VerificationRequest;
}> = ({ txnId, request }) => {
const [, updateState] = useState();
const [timeout, setRequestTimeout] = useState(request.timeout);
/* Re-render if something changes state */
useTypedEventEmitter(request, VerificationRequestEvent.Change, updateState);
/* Keep re-rendering if there's a timeout */
useEffect(() => {
if (request.timeout == 0) return;
/* Note that request.timeout is a getter, so its value changes */
const id = window.setInterval(() => {
setRequestTimeout(request.timeout);
}, 500);
return () => {
clearInterval(id);
};
}, [request]);
return (
<div className="mx_DevTools_VerificationRequest">
<dl>
<dt>{_t("devtools|phase_transaction")}</dt>
<dd>{txnId}</dd>
<dt>{_t("devtools|phase")}</dt>
<dd>{PHASE_MAP[request.phase] ? _t(PHASE_MAP[request.phase]) : request.phase}</dd>
<dt>{_t("devtools|timeout")}</dt>
<dd>{timeout === null ? _t("devtools|timeout_none") : Math.floor(timeout / 1000)}</dd>
<dt>{_t("devtools|methods")}</dt>
<dd>{request.methods && request.methods.join(", ")}</dd>
<dt>{_t("devtools|other_user")}</dt>
<dd>{request.otherUserId}</dd>
</dl>
</div>
);
};
const VerificationExplorer: Tool = ({ onBack }: IDevtoolsProps) => {
const cli = useContext(MatrixClientContext);
const context = useContext(DevtoolsContext);
const requests = useTypedEventEmitterState(cli, CryptoEvent.VerificationRequestReceived, () => {
return (
cli.crypto?.inRoomVerificationRequests["requestsByRoomId"]?.get(context.room.roomId) ??
new Map<string, VerificationRequest>()
);
});
return (
<BaseTool onBack={onBack}>
{Array.from(requests.entries())
.reverse()
.map(([txnId, request]) => (
<VerificationRequestExplorer txnId={txnId} request={request} key={txnId} />
))}
{requests.size < 1 && _t("devtools|no_verification_requests_found")}
</BaseTool>
);
};
export default VerificationExplorer;

View file

@ -0,0 +1,65 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useContext, useState } from "react";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { useEventEmitterState } from "../../../../hooks/useEventEmitter";
import { _t } from "../../../../languageHandler";
import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool";
import WidgetStore, { IApp } from "../../../../stores/WidgetStore";
import { UPDATE_EVENT } from "../../../../stores/AsyncStore";
import FilteredList from "./FilteredList";
import { StateEventEditor } from "./RoomState";
const WidgetExplorer: React.FC<IDevtoolsProps> = ({ onBack }) => {
const context = useContext(DevtoolsContext);
const [query, setQuery] = useState("");
const [widget, setWidget] = useState<IApp | null>(null);
const widgets = useEventEmitterState(WidgetStore.instance, UPDATE_EVENT, () => {
return WidgetStore.instance.getApps(context.room.roomId);
});
if (widget && widgets.includes(widget)) {
const onBack = (): void => {
setWidget(null);
};
const allState = Array.from(
Array.from(context.room.currentState.events.values()).map((e: Map<string, MatrixEvent>) => {
return e.values();
}),
).reduce((p, c) => {
p.push(...c);
return p;
}, [] as MatrixEvent[]);
const event = allState.find((ev) => ev.getId() === widget.eventId);
if (!event) {
// "should never happen"
return <BaseTool onBack={onBack}>{_t("devtools|failed_to_find_widget")}</BaseTool>;
}
return <StateEventEditor mxEvent={event} onBack={onBack} />;
}
return (
<BaseTool onBack={onBack}>
<FilteredList query={query} onChange={setQuery}>
{widgets.map((w) => (
<button className="mx_DevTools_button" key={w.url + w.eventId} onClick={() => setWidget(w)}>
{w.url}
</button>
))}
</FilteredList>
</BaseTool>
);
};
export default WidgetExplorer;

View file

@ -0,0 +1,66 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React, { useState } from "react";
import { _t } from "../../../../languageHandler";
import BaseDialog from "../BaseDialog";
import { getOidcLogoutUrl } from "../../../../utils/oidc/getOidcLogoutUrl";
import AccessibleButton from "../../elements/AccessibleButton";
export interface OidcLogoutDialogProps {
delegatedAuthAccountUrl: string;
deviceId: string;
onFinished(ok?: boolean): void;
}
/**
* Handle logout of OIDC sessions other than the current session
* - ask for user confirmation to open the delegated auth provider
* - open the auth provider in a new tab
* - wait for the user to return and close the modal, we assume the user has completed sign out of the session in auth provider UI
* and trigger a refresh of the session list
*/
export const OidcLogoutDialog: React.FC<OidcLogoutDialogProps> = ({
delegatedAuthAccountUrl,
deviceId,
onFinished,
}) => {
const [hasOpenedLogoutLink, setHasOpenedLogoutLink] = useState(false);
const logoutUrl = getOidcLogoutUrl(delegatedAuthAccountUrl, deviceId);
return (
<BaseDialog onFinished={onFinished} title={_t("action|sign_out")} contentId="mx_Dialog_content">
<div className="mx_Dialog_content" id="mx_Dialog_content">
{_t("auth|oidc|logout_redirect_warning")}
</div>
<div className="mx_Dialog_buttons">
{hasOpenedLogoutLink ? (
<AccessibleButton kind="primary" onClick={() => onFinished(true)}>
{_t("action|close")}
</AccessibleButton>
) : (
<>
<AccessibleButton kind="secondary" onClick={() => onFinished(false)}>
{_t("action|cancel")}
</AccessibleButton>
<AccessibleButton
element="a"
onClick={() => setHasOpenedLogoutLink(true)}
kind="primary"
href={logoutUrl}
target="_blank"
>
{_t("action|continue")}
</AccessibleButton>
</>
)}
</div>
</BaseDialog>
);
};

View file

@ -0,0 +1,448 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2018-2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { debounce } from "lodash";
import classNames from "classnames";
import React, { ChangeEvent, FormEvent } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { decodeRecoveryKey } from "matrix-js-sdk/src/crypto-api";
import { SecretStorage } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import Field from "../../elements/Field";
import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
import { _t } from "../../../../languageHandler";
import { accessSecretStorage } from "../../../../SecurityManager";
import Modal from "../../../../Modal";
import InteractiveAuthDialog from "../InteractiveAuthDialog";
import DialogButtons from "../../elements/DialogButtons";
import BaseDialog from "../BaseDialog";
import { chromeFileInputFix } from "../../../../utils/BrowserWorkarounds";
// Maximum acceptable size of a key file. It's 59 characters including the spaces we encode,
// so this should be plenty and allow for people putting extra whitespace in the file because
// maybe that's a thing people would do?
const KEY_FILE_MAX_SIZE = 128;
// Don't shout at the user that their key is invalid every time they type a key: wait a short time
const VALIDATION_THROTTLE_MS = 200;
export type KeyParams = { passphrase?: string; recoveryKey?: string };
interface IProps {
keyInfo: SecretStorage.SecretStorageKeyDescription;
checkPrivateKey: (k: KeyParams) => Promise<boolean>;
onFinished(result?: false | KeyParams): void;
}
interface IState {
recoveryKey: string;
recoveryKeyValid: boolean | null;
recoveryKeyCorrect: boolean | null;
recoveryKeyFileError: boolean | null;
forceRecoveryKey: boolean;
passPhrase: string;
keyMatches: boolean | null;
resetting: boolean;
}
/*
* Access Secure Secret Storage by requesting the user's passphrase.
*/
export default class AccessSecretStorageDialog extends React.PureComponent<IProps, IState> {
private fileUpload = React.createRef<HTMLInputElement>();
private inputRef = React.createRef<HTMLInputElement>();
public constructor(props: IProps) {
super(props);
this.state = {
recoveryKey: "",
recoveryKeyValid: null,
recoveryKeyCorrect: null,
recoveryKeyFileError: null,
forceRecoveryKey: false,
passPhrase: "",
keyMatches: null,
resetting: false,
};
}
private onCancel = (): void => {
if (this.state.resetting) {
this.setState({ resetting: false });
}
this.props.onFinished(false);
};
private onUseRecoveryKeyClick = (): void => {
this.setState({
forceRecoveryKey: true,
});
};
private validateRecoveryKeyOnChange = debounce(async (): Promise<void> => {
await this.validateRecoveryKey();
}, VALIDATION_THROTTLE_MS);
private async validateRecoveryKey(): Promise<void> {
if (this.state.recoveryKey === "") {
this.setState({
recoveryKeyValid: null,
recoveryKeyCorrect: null,
});
return;
}
try {
const cli = MatrixClientPeg.safeGet();
const decodedKey = decodeRecoveryKey(this.state.recoveryKey);
const correct = await cli.secretStorage.checkKey(decodedKey, this.props.keyInfo);
this.setState({
recoveryKeyValid: true,
recoveryKeyCorrect: correct,
});
} catch (e) {
this.setState({
recoveryKeyValid: false,
recoveryKeyCorrect: false,
});
}
}
private onRecoveryKeyChange = (ev: ChangeEvent<HTMLInputElement>): void => {
this.setState({
recoveryKey: ev.target.value,
recoveryKeyFileError: null,
});
// also clear the file upload control so that the user can upload the same file
// the did before (otherwise the onchange wouldn't fire)
if (this.fileUpload.current) this.fileUpload.current.value = "";
// We don't use Field's validation here because a) we want it in a separate place rather
// than in a tooltip and b) we want it to display feedback based on the uploaded file
// as well as the text box. Ideally we would refactor Field's validation logic so we could
// re-use some of it.
this.validateRecoveryKeyOnChange();
};
private onRecoveryKeyFileChange = async (ev: ChangeEvent<HTMLInputElement>): Promise<void> => {
if (!ev.target.files?.length) return;
const f = ev.target.files[0];
if (f.size > KEY_FILE_MAX_SIZE) {
this.setState({
recoveryKeyFileError: true,
recoveryKeyCorrect: false,
recoveryKeyValid: false,
});
} else {
const contents = await f.text();
// test it's within the base58 alphabet. We could be more strict here, eg. require the
// right number of characters, but it's really just to make sure that what we're reading is
// text because we'll put it in the text field.
if (/^[123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz\s]+$/.test(contents)) {
this.setState({
recoveryKeyFileError: null,
recoveryKey: contents.trim(),
});
await this.validateRecoveryKey();
} else {
this.setState({
recoveryKeyFileError: true,
recoveryKeyCorrect: false,
recoveryKeyValid: false,
recoveryKey: "",
});
}
}
};
private onRecoveryKeyFileUploadClick = (): void => {
this.fileUpload.current?.click();
};
private onPassPhraseNext = async (ev: FormEvent<HTMLFormElement> | React.MouseEvent): Promise<void> => {
ev.preventDefault();
if (this.state.passPhrase.length <= 0) {
this.inputRef.current?.focus();
return;
}
this.setState({ keyMatches: null });
const input = { passphrase: this.state.passPhrase };
const keyMatches = await this.props.checkPrivateKey(input);
if (keyMatches) {
this.props.onFinished(input);
} else {
this.setState({ keyMatches });
this.inputRef.current?.focus();
}
};
private onRecoveryKeyNext = async (ev: FormEvent<HTMLFormElement> | React.MouseEvent): Promise<void> => {
ev.preventDefault();
if (!this.state.recoveryKeyValid) return;
this.setState({ keyMatches: null });
const input = { recoveryKey: this.state.recoveryKey };
const keyMatches = await this.props.checkPrivateKey(input);
if (keyMatches) {
this.props.onFinished(input);
} else {
this.setState({ keyMatches });
}
};
private onPassPhraseChange = (ev: ChangeEvent<HTMLInputElement>): void => {
this.setState({
passPhrase: ev.target.value,
keyMatches: null,
});
};
private onResetAllClick = (ev: ButtonEvent): void => {
ev.preventDefault();
this.setState({ resetting: true });
};
private onConfirmResetAllClick = async (): Promise<void> => {
// Hide ourselves so the user can interact with the reset dialogs.
// We don't conclude the promise chain (onFinished) yet to avoid confusing
// any upstream code flows.
//
// Note: this will unmount us, so don't call `setState` or anything in the
// rest of this function.
Modal.toggleCurrentDialogVisibility();
try {
// Force reset secret storage (which resets the key backup)
await accessSecretStorage(async (): Promise<void> => {
// Now reset cross-signing so everything Just Works™ again.
const cli = MatrixClientPeg.safeGet();
await cli.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async (makeRequest): Promise<void> => {
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
title: _t("encryption|bootstrap_title"),
matrixClient: cli,
makeRequest,
});
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Cross-signing key upload auth canceled");
}
},
setupNewCrossSigning: true,
});
// Now we can indicate that the user is done pressing buttons, finally.
// Upstream flows will detect the new secret storage, key backup, etc and use it.
this.props.onFinished({});
}, true);
} catch (e) {
logger.error(e);
this.props.onFinished(false);
}
};
private getKeyValidationText(): string {
if (this.state.recoveryKeyFileError) {
return _t("encryption|access_secret_storage_dialog|key_validation_text|wrong_file_type");
} else if (this.state.recoveryKeyCorrect) {
return _t("encryption|access_secret_storage_dialog|key_validation_text|recovery_key_is_correct");
} else if (this.state.recoveryKeyValid) {
return _t("encryption|access_secret_storage_dialog|key_validation_text|wrong_security_key");
} else if (this.state.recoveryKeyValid === null) {
return "";
} else {
return _t("encryption|access_secret_storage_dialog|key_validation_text|invalid_security_key");
}
}
public render(): React.ReactNode {
const hasPassphrase = this.props.keyInfo?.passphrase?.salt && this.props.keyInfo?.passphrase?.iterations;
const resetLine = (
<strong className="mx_AccessSecretStorageDialog_reset">
{_t("encryption|reset_all_button", undefined, {
a: (sub) => (
<AccessibleButton
kind="link_inline"
onClick={this.onResetAllClick}
className="mx_AccessSecretStorageDialog_reset_link"
>
{sub}
</AccessibleButton>
),
})}
</strong>
);
let content;
let title;
let titleClass;
if (this.state.resetting) {
title = _t("encryption|access_secret_storage_dialog|reset_title");
titleClass = ["mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_resetBadge"];
content = (
<div>
<p>{_t("encryption|access_secret_storage_dialog|reset_warning_1")}</p>
<p>{_t("encryption|access_secret_storage_dialog|reset_warning_2")}</p>
<DialogButtons
primaryButton={_t("action|reset")}
onPrimaryButtonClick={this.onConfirmResetAllClick}
hasCancel={true}
onCancel={this.onCancel}
focus={false}
primaryButtonClass="danger"
/>
</div>
);
} else if (hasPassphrase && !this.state.forceRecoveryKey) {
title = _t("encryption|access_secret_storage_dialog|security_phrase_title");
titleClass = ["mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_securePhraseTitle"];
let keyStatus;
if (this.state.keyMatches === false) {
keyStatus = (
<div className="mx_AccessSecretStorageDialog_keyStatus">
{"\uD83D\uDC4E "}
{_t("encryption|access_secret_storage_dialog|security_phrase_incorrect_error")}
</div>
);
} else {
keyStatus = <div className="mx_AccessSecretStorageDialog_keyStatus" />;
}
content = (
<div>
<p>
{_t(
"encryption|access_secret_storage_dialog|enter_phrase_or_key_prompt",
{},
{
button: (s) => (
<AccessibleButton kind="link_inline" onClick={this.onUseRecoveryKeyClick}>
{s}
</AccessibleButton>
),
},
)}
</p>
<form className="mx_AccessSecretStorageDialog_primaryContainer" onSubmit={this.onPassPhraseNext}>
<Field
inputRef={this.inputRef}
id="mx_passPhraseInput"
className="mx_AccessSecretStorageDialog_passPhraseInput"
type="password"
label={_t("encryption|access_secret_storage_dialog|security_phrase_title")}
value={this.state.passPhrase}
onChange={this.onPassPhraseChange}
autoFocus={true}
autoComplete="new-password"
/>
{keyStatus}
<DialogButtons
primaryButton={_t("action|continue")}
onPrimaryButtonClick={this.onPassPhraseNext}
hasCancel={true}
onCancel={this.onCancel}
focus={false}
primaryDisabled={this.state.passPhrase.length === 0}
additive={resetLine}
/>
</form>
</div>
);
} else {
title = _t("encryption|access_secret_storage_dialog|security_key_title");
titleClass = ["mx_AccessSecretStorageDialog_titleWithIcon mx_AccessSecretStorageDialog_secureBackupTitle"];
const feedbackClasses = classNames({
"mx_AccessSecretStorageDialog_recoveryKeyFeedback": true,
"mx_AccessSecretStorageDialog_recoveryKeyFeedback--valid": this.state.recoveryKeyCorrect === true,
"mx_AccessSecretStorageDialog_recoveryKeyFeedback--invalid": this.state.recoveryKeyCorrect === false,
});
const recoveryKeyFeedback = <div className={feedbackClasses}>{this.getKeyValidationText()}</div>;
content = (
<div>
<p>{_t("encryption|access_secret_storage_dialog|use_security_key_prompt")}</p>
<form
className="mx_AccessSecretStorageDialog_primaryContainer"
onSubmit={this.onRecoveryKeyNext}
spellCheck={false}
autoComplete="off"
>
<div className="mx_AccessSecretStorageDialog_recoveryKeyEntry">
<div className="mx_AccessSecretStorageDialog_recoveryKeyEntry_textInput">
<Field
type="password"
id="mx_securityKey"
label={_t("encryption|access_secret_storage_dialog|security_key_title")}
value={this.state.recoveryKey}
onChange={this.onRecoveryKeyChange}
autoFocus={true}
forceValidity={this.state.recoveryKeyCorrect ?? undefined}
autoComplete="off"
/>
</div>
<span className="mx_AccessSecretStorageDialog_recoveryKeyEntry_entryControlSeparatorText">
{_t("encryption|access_secret_storage_dialog|separator", {
recoveryFile: "",
securityKey: "",
})}
</span>
<div>
<input
type="file"
className="mx_AccessSecretStorageDialog_recoveryKeyEntry_fileInput"
ref={this.fileUpload}
onClick={chromeFileInputFix}
onChange={this.onRecoveryKeyFileChange}
/>
<AccessibleButton kind="primary" onClick={this.onRecoveryKeyFileUploadClick}>
{_t("action|upload")}
</AccessibleButton>
</div>
</div>
{recoveryKeyFeedback}
<DialogButtons
primaryButton={_t("action|continue")}
onPrimaryButtonClick={this.onRecoveryKeyNext}
hasCancel={true}
cancelButton={_t("action|go_back")}
cancelButtonClass="warning"
onCancel={this.onCancel}
focus={false}
primaryDisabled={!this.state.recoveryKeyValid}
additive={resetLine}
/>
</form>
</div>
);
}
return (
<BaseDialog
className="mx_AccessSecretStorageDialog"
onFinished={this.props.onFinished}
title={title}
titleClass={titleClass}
>
<div>{content}</div>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,49 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { _t } from "../../../../languageHandler";
import BaseDialog from "../BaseDialog";
import DialogButtons from "../../elements/DialogButtons";
interface IProps {
onFinished: (success?: boolean) => void;
}
export default class ConfirmDestroyCrossSigningDialog extends React.Component<IProps> {
private onConfirm = (): void => {
this.props.onFinished(true);
};
private onDecline = (): void => {
this.props.onFinished(false);
};
public render(): React.ReactNode {
return (
<BaseDialog
className="mx_ConfirmDestroyCrossSigningDialog"
hasCancel={true}
onFinished={this.props.onFinished}
title={_t("encryption|destroy_cross_signing_dialog|title")}
>
<div className="mx_ConfirmDestroyCrossSigningDialog_content">
<p>{_t("encryption|destroy_cross_signing_dialog|warning")}</p>
</div>
<DialogButtons
primaryButton={_t("encryption|destroy_cross_signing_dialog|primary_button_text")}
onPrimaryButtonClick={this.onConfirm}
primaryButtonClass="danger"
cancelButton={_t("action|cancel")}
onCancel={this.onDecline}
/>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,195 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2018, 2019 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.
*/
import React from "react";
import { CrossSigningKeys, AuthDict, MatrixError, UIAFlow, UIAResponse } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import { _t } from "../../../../languageHandler";
import Modal from "../../../../Modal";
import { SSOAuthEntry } from "../../auth/InteractiveAuthEntryComponents";
import DialogButtons from "../../elements/DialogButtons";
import BaseDialog from "../BaseDialog";
import Spinner from "../../elements/Spinner";
import InteractiveAuthDialog from "../InteractiveAuthDialog";
interface IProps {
accountPassword?: string;
tokenLogin?: boolean;
onFinished: (success?: boolean) => void;
}
interface IState {
error: boolean;
canUploadKeysWithPasswordOnly: boolean | null;
accountPassword: string;
}
/*
* Walks the user through the process of creating a cross-signing keys. In most
* cases, only a spinner is shown, but for more complex auth like SSO, the user
* may need to complete some steps to proceed.
*/
export default class CreateCrossSigningDialog extends React.PureComponent<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
error: false,
// Does the server offer a UI auth flow with just m.login.password
// for /keys/device_signing/upload?
// If we have an account password in memory, let's simplify and
// assume it means password auth is also supported for device
// signing key upload as well. This avoids hitting the server to
// test auth flows, which may be slow under high load.
canUploadKeysWithPasswordOnly: props.accountPassword ? true : null,
accountPassword: props.accountPassword || "",
};
if (!this.state.accountPassword) {
this.queryKeyUploadAuth();
}
}
public componentDidMount(): void {
this.bootstrapCrossSigning();
}
private async queryKeyUploadAuth(): Promise<void> {
try {
await MatrixClientPeg.safeGet().uploadDeviceSigningKeys(undefined, {} as CrossSigningKeys);
// We should never get here: the server should always require
// UI auth to upload device signing keys. If we do, we upload
// no keys which would be a no-op.
logger.log("uploadDeviceSigningKeys unexpectedly succeeded without UI auth!");
} catch (error) {
if (!(error instanceof MatrixError) || !error.data || !error.data.flows) {
logger.log("uploadDeviceSigningKeys advertised no flows!");
return;
}
const canUploadKeysWithPasswordOnly = error.data.flows.some((f: UIAFlow) => {
return f.stages.length === 1 && f.stages[0] === "m.login.password";
});
this.setState({
canUploadKeysWithPasswordOnly,
});
}
}
private doBootstrapUIAuth = async (
makeRequest: (authData: AuthDict) => Promise<UIAResponse<void>>,
): Promise<void> => {
if (this.state.canUploadKeysWithPasswordOnly && this.state.accountPassword) {
await makeRequest({
type: "m.login.password",
identifier: {
type: "m.id.user",
user: MatrixClientPeg.safeGet().getUserId(),
},
password: this.state.accountPassword,
});
} else if (this.props.tokenLogin) {
// We are hoping the grace period is active
await makeRequest({});
} else {
const dialogAesthetics = {
[SSOAuthEntry.PHASE_PREAUTH]: {
title: _t("auth|uia|sso_title"),
body: _t("auth|uia|sso_preauth_body"),
continueText: _t("auth|sso"),
continueKind: "primary",
},
[SSOAuthEntry.PHASE_POSTAUTH]: {
title: _t("encryption|confirm_encryption_setup_title"),
body: _t("encryption|confirm_encryption_setup_body"),
continueText: _t("action|confirm"),
continueKind: "primary",
},
};
const { finished } = Modal.createDialog(InteractiveAuthDialog, {
title: _t("encryption|bootstrap_title"),
matrixClient: MatrixClientPeg.safeGet(),
makeRequest,
aestheticsForStagePhases: {
[SSOAuthEntry.LOGIN_TYPE]: dialogAesthetics,
[SSOAuthEntry.UNSTABLE_LOGIN_TYPE]: dialogAesthetics,
},
});
const [confirmed] = await finished;
if (!confirmed) {
throw new Error("Cross-signing key upload auth canceled");
}
}
};
private bootstrapCrossSigning = async (): Promise<void> => {
this.setState({
error: false,
});
try {
const cli = MatrixClientPeg.safeGet();
await cli.bootstrapCrossSigning({
authUploadDeviceSigningKeys: this.doBootstrapUIAuth,
});
this.props.onFinished(true);
} catch (e) {
if (this.props.tokenLogin) {
// ignore any failures, we are relying on grace period here
this.props.onFinished(false);
return;
}
this.setState({ error: true });
logger.error("Error bootstrapping cross-signing", e);
}
};
private onCancel = (): void => {
this.props.onFinished(false);
};
public render(): React.ReactNode {
let content;
if (this.state.error) {
content = (
<div>
<p>{_t("encryption|unable_to_setup_keys_error")}</p>
<div className="mx_Dialog_buttons">
<DialogButtons
primaryButton={_t("action|retry")}
onPrimaryButtonClick={this.bootstrapCrossSigning}
onCancel={this.onCancel}
/>
</div>
</div>
);
} else {
content = (
<div>
<Spinner />
</div>
);
}
return (
<BaseDialog
className="mx_CreateCrossSigningDialog"
onFinished={this.props.onFinished}
title={_t("encryption|bootstrap_title")}
hasCancel={false}
fixedWidth={false}
>
<div>{content}</div>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,515 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2018, 2019 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.
*/
import React, { ChangeEvent } from "react";
import { MatrixClient, MatrixError, SecretStorage } from "matrix-js-sdk/src/matrix";
import { decodeRecoveryKey, KeyBackupInfo } from "matrix-js-sdk/src/crypto-api";
import { IKeyBackupRestoreResult } from "matrix-js-sdk/src/crypto/keybackup";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import { _t } from "../../../../languageHandler";
import { accessSecretStorage } from "../../../../SecurityManager";
import Spinner from "../../elements/Spinner";
import DialogButtons from "../../elements/DialogButtons";
import AccessibleButton from "../../elements/AccessibleButton";
import BaseDialog from "../BaseDialog";
enum RestoreType {
Passphrase = "passphrase",
RecoveryKey = "recovery_key",
SecretStorage = "secret_storage",
}
enum ProgressState {
PreFetch = "prefetch",
Fetch = "fetch",
LoadKeys = "load_keys",
}
interface IProps {
// if false, will close the dialog as soon as the restore completes successfully
// default: true
showSummary?: boolean;
// If specified, gather the key from the user but then call the function with the backup
// key rather than actually (necessarily) restoring the backup.
keyCallback?: (key: Uint8Array) => void;
onFinished(done?: boolean): void;
}
interface IState {
backupInfo: KeyBackupInfo | null;
backupKeyStored: Record<string, SecretStorage.SecretStorageKeyDescription> | null;
loading: boolean;
loadError: boolean | null;
restoreError: unknown | null;
recoveryKey: string;
recoverInfo: IKeyBackupRestoreResult | null;
recoveryKeyValid: boolean;
forceRecoveryKey: boolean;
passPhrase: string;
restoreType: RestoreType | null;
progress: {
stage: ProgressState | string;
total?: number;
successes?: number;
failures?: number;
};
}
/*
* Dialog for restoring e2e keys from a backup and the user's recovery key
*/
export default class RestoreKeyBackupDialog extends React.PureComponent<IProps, IState> {
public static defaultProps: Partial<IProps> = {
showSummary: true,
};
public constructor(props: IProps) {
super(props);
this.state = {
backupInfo: null,
backupKeyStored: null,
loading: false,
loadError: null,
restoreError: null,
recoveryKey: "",
recoverInfo: null,
recoveryKeyValid: false,
forceRecoveryKey: false,
passPhrase: "",
restoreType: null,
progress: { stage: ProgressState.PreFetch },
};
}
public componentDidMount(): void {
this.loadBackupStatus();
}
private onCancel = (): void => {
this.props.onFinished(false);
};
private onDone = (): void => {
this.props.onFinished(true);
};
private onUseRecoveryKeyClick = (): void => {
this.setState({
forceRecoveryKey: true,
});
};
private progressCallback = (data: IState["progress"]): void => {
this.setState({
progress: data,
});
};
private onResetRecoveryClick = (): void => {
this.props.onFinished(false);
accessSecretStorage(async (): Promise<void> => {}, /* forceReset = */ true);
};
/**
* Check if the recovery key is valid
* @param recoveryKey
* @private
*/
private isValidRecoveryKey(recoveryKey: string): boolean {
try {
decodeRecoveryKey(recoveryKey);
return true;
} catch (e) {
return false;
}
}
private onRecoveryKeyChange = (e: ChangeEvent<HTMLInputElement>): void => {
this.setState({
recoveryKey: e.target.value,
recoveryKeyValid: this.isValidRecoveryKey(e.target.value),
});
};
private onPassPhraseNext = async (): Promise<void> => {
if (!this.state.backupInfo) return;
this.setState({
loading: true,
restoreError: null,
restoreType: RestoreType.Passphrase,
});
try {
// We do still restore the key backup: we must ensure that the key backup key
// is the right one and restoring it is currently the only way we can do this.
const recoverInfo = await MatrixClientPeg.safeGet().restoreKeyBackupWithPassword(
this.state.passPhrase,
undefined,
undefined,
this.state.backupInfo,
{ progressCallback: this.progressCallback },
);
if (this.props.keyCallback) {
const key = await MatrixClientPeg.safeGet().keyBackupKeyFromPassword(
this.state.passPhrase,
this.state.backupInfo,
);
this.props.keyCallback(key);
}
if (!this.props.showSummary) {
this.props.onFinished(true);
return;
}
this.setState({
loading: false,
recoverInfo,
});
} catch (e) {
logger.log("Error restoring backup", e);
this.setState({
loading: false,
restoreError: e,
});
}
};
private onRecoveryKeyNext = async (): Promise<void> => {
if (!this.state.recoveryKeyValid || !this.state.backupInfo) return;
this.setState({
loading: true,
restoreError: null,
restoreType: RestoreType.RecoveryKey,
});
try {
const recoverInfo = await MatrixClientPeg.safeGet().restoreKeyBackupWithRecoveryKey(
this.state.recoveryKey,
undefined,
undefined,
this.state.backupInfo,
{ progressCallback: this.progressCallback },
);
if (this.props.keyCallback) {
const key = decodeRecoveryKey(this.state.recoveryKey);
this.props.keyCallback(key);
}
if (!this.props.showSummary) {
this.props.onFinished(true);
return;
}
this.setState({
loading: false,
recoverInfo,
});
} catch (e) {
logger.log("Error restoring backup", e);
this.setState({
loading: false,
restoreError: e,
});
}
};
private onPassPhraseChange = (e: ChangeEvent<HTMLInputElement>): void => {
this.setState({
passPhrase: e.target.value,
});
};
private async restoreWithSecretStorage(): Promise<void> {
this.setState({
loading: true,
restoreError: null,
restoreType: RestoreType.SecretStorage,
});
try {
// `accessSecretStorage` may prompt for storage access as needed.
await accessSecretStorage(async (): Promise<void> => {
if (!this.state.backupInfo) return;
await MatrixClientPeg.safeGet().restoreKeyBackupWithSecretStorage(
this.state.backupInfo,
undefined,
undefined,
{ progressCallback: this.progressCallback },
);
});
this.setState({
loading: false,
});
} catch (e) {
logger.log("Error restoring backup", e);
this.setState({
restoreError: e,
loading: false,
});
}
}
private async restoreWithCachedKey(backupInfo: KeyBackupInfo | null): Promise<boolean> {
if (!backupInfo) return false;
try {
const recoverInfo = await MatrixClientPeg.safeGet().restoreKeyBackupWithCache(
undefined /* targetRoomId */,
undefined /* targetSessionId */,
backupInfo,
{ progressCallback: this.progressCallback },
);
this.setState({
recoverInfo,
});
return true;
} catch (e) {
logger.log("restoreWithCachedKey failed:", e);
return false;
}
}
private async loadBackupStatus(): Promise<void> {
this.setState({
loading: true,
loadError: null,
});
try {
const cli = MatrixClientPeg.safeGet();
const backupInfo = await cli.getKeyBackupVersion();
const has4S = await cli.hasSecretStorageKey();
const backupKeyStored = has4S ? await cli.isKeyBackupKeyStored() : null;
this.setState({
backupInfo,
backupKeyStored,
});
const gotCache = await this.restoreWithCachedKey(backupInfo);
if (gotCache) {
logger.log("RestoreKeyBackupDialog: found cached backup key");
this.setState({
loading: false,
});
return;
}
// If the backup key is stored, we can proceed directly to restore.
if (backupKeyStored) {
return this.restoreWithSecretStorage();
}
this.setState({
loadError: null,
loading: false,
});
} catch (e) {
logger.log("Error loading backup status", e);
this.setState({
loadError: true,
loading: false,
});
}
}
public render(): React.ReactNode {
const backupHasPassphrase =
this.state.backupInfo &&
this.state.backupInfo.auth_data &&
this.state.backupInfo.auth_data.private_key_salt &&
this.state.backupInfo.auth_data.private_key_iterations;
let content;
let title;
if (this.state.loading) {
title = _t("encryption|access_secret_storage_dialog|restoring");
let details;
if (this.state.progress.stage === ProgressState.Fetch) {
details = _t("restore_key_backup_dialog|key_fetch_in_progress");
} else if (this.state.progress.stage === ProgressState.LoadKeys) {
const { total, successes, failures } = this.state.progress;
details = _t("restore_key_backup_dialog|load_keys_progress", {
total,
completed: (successes ?? 0) + (failures ?? 0),
});
} else if (this.state.progress.stage === ProgressState.PreFetch) {
details = _t("restore_key_backup_dialog|key_fetch_in_progress");
}
content = (
<div>
<div>{details}</div>
<Spinner />
</div>
);
} else if (this.state.loadError) {
title = _t("common|error");
content = _t("restore_key_backup_dialog|load_error_content");
} else if (this.state.restoreError) {
if (
this.state.restoreError instanceof MatrixError &&
this.state.restoreError.errcode === MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY
) {
if (this.state.restoreType === RestoreType.RecoveryKey) {
title = _t("restore_key_backup_dialog|recovery_key_mismatch_title");
content = (
<div>
<p>{_t("restore_key_backup_dialog|recovery_key_mismatch_description")}</p>
</div>
);
} else {
title = _t("restore_key_backup_dialog|incorrect_security_phrase_title");
content = (
<div>
<p>{_t("restore_key_backup_dialog|incorrect_security_phrase_dialog")}</p>
</div>
);
}
} else {
title = _t("common|error");
content = _t("restore_key_backup_dialog|restore_failed_error");
}
} else if (this.state.backupInfo === null) {
title = _t("common|error");
content = _t("restore_key_backup_dialog|no_backup_error");
} else if (this.state.recoverInfo) {
title = _t("restore_key_backup_dialog|keys_restored_title");
let failedToDecrypt;
if (this.state.recoverInfo.total > this.state.recoverInfo.imported) {
failedToDecrypt = (
<p>
{_t("restore_key_backup_dialog|count_of_decryption_failures", {
failedCount: this.state.recoverInfo.total - this.state.recoverInfo.imported,
})}
</p>
);
}
content = (
<div>
<p>
{_t("restore_key_backup_dialog|count_of_successfully_restored_keys", {
sessionCount: this.state.recoverInfo.imported,
})}
</p>
{failedToDecrypt}
<DialogButtons
primaryButton={_t("action|ok")}
onPrimaryButtonClick={this.onDone}
hasCancel={false}
focus={true}
/>
</div>
);
} else if (backupHasPassphrase && !this.state.forceRecoveryKey) {
title = _t("restore_key_backup_dialog|enter_phrase_title");
content = (
<div>
<p>
{_t("restore_key_backup_dialog|key_backup_warning", {}, { b: (sub) => <strong>{sub}</strong> })}
</p>
<p>{_t("restore_key_backup_dialog|enter_phrase_description")}</p>
<form className="mx_RestoreKeyBackupDialog_primaryContainer">
<input
type="password"
className="mx_RestoreKeyBackupDialog_passPhraseInput"
onChange={this.onPassPhraseChange}
value={this.state.passPhrase}
autoFocus={true}
/>
<DialogButtons
primaryButton={_t("action|next")}
onPrimaryButtonClick={this.onPassPhraseNext}
primaryIsSubmit={true}
hasCancel={true}
onCancel={this.onCancel}
focus={false}
/>
</form>
{_t(
"restore_key_backup_dialog|phrase_forgotten_text",
{},
{
button1: (s) => (
<AccessibleButton kind="link_inline" onClick={this.onUseRecoveryKeyClick}>
{s}
</AccessibleButton>
),
button2: (s) => (
<AccessibleButton kind="link_inline" onClick={this.onResetRecoveryClick}>
{s}
</AccessibleButton>
),
},
)}
</div>
);
} else {
title = _t("restore_key_backup_dialog|enter_key_title");
let keyStatus;
if (this.state.recoveryKey.length === 0) {
keyStatus = <div className="mx_RestoreKeyBackupDialog_keyStatus" />;
} else if (this.state.recoveryKeyValid) {
keyStatus = (
<div className="mx_RestoreKeyBackupDialog_keyStatus">
{"\uD83D\uDC4D "}
{_t("restore_key_backup_dialog|key_is_valid")}
</div>
);
} else {
keyStatus = (
<div className="mx_RestoreKeyBackupDialog_keyStatus">
{"\uD83D\uDC4E "}
{_t("restore_key_backup_dialog|key_is_invalid")}
</div>
);
}
content = (
<div>
<p>
{_t("restore_key_backup_dialog|key_backup_warning", {}, { b: (sub) => <strong>{sub}</strong> })}
</p>
<p>{_t("restore_key_backup_dialog|enter_key_description")}</p>
<div className="mx_RestoreKeyBackupDialog_primaryContainer">
<input
className="mx_RestoreKeyBackupDialog_recoveryKeyInput"
onChange={this.onRecoveryKeyChange}
value={this.state.recoveryKey}
autoFocus={true}
/>
{keyStatus}
<DialogButtons
primaryButton={_t("action|next")}
onPrimaryButtonClick={this.onRecoveryKeyNext}
hasCancel={true}
onCancel={this.onCancel}
focus={false}
primaryDisabled={!this.state.recoveryKeyValid}
/>
</div>
{_t(
"restore_key_backup_dialog|key_forgotten_text",
{},
{
button: (s) => (
<AccessibleButton kind="link_inline" onClick={this.onResetRecoveryClick}>
{s}
</AccessibleButton>
),
},
)}
</div>
);
}
return (
<BaseDialog className="mx_RestoreKeyBackupDialog" onFinished={this.props.onFinished} title={title}>
<div className="mx_RestoreKeyBackupDialog_content">{content}</div>
</BaseDialog>
);
}
}

View file

@ -0,0 +1,64 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import SetupEncryptionBody from "../../../structures/auth/SetupEncryptionBody";
import BaseDialog from "../BaseDialog";
import { _t } from "../../../../languageHandler";
import { SetupEncryptionStore, Phase } from "../../../../stores/SetupEncryptionStore";
function iconFromPhase(phase?: Phase): string {
if (phase === Phase.Done) {
return require("../../../../../res/img/e2e/verified-deprecated.svg").default;
} else {
return require("../../../../../res/img/e2e/warning-deprecated.svg").default;
}
}
interface IProps {
onFinished(): void;
}
interface IState {
icon: string;
}
export default class SetupEncryptionDialog extends React.Component<IProps, IState> {
private store: SetupEncryptionStore;
public constructor(props: IProps) {
super(props);
this.store = SetupEncryptionStore.sharedInstance();
this.state = { icon: iconFromPhase(this.store.phase) };
}
public componentDidMount(): void {
this.store.on("update", this.onStoreUpdate);
}
public componentWillUnmount(): void {
this.store.removeListener("update", this.onStoreUpdate);
}
private onStoreUpdate = (): void => {
this.setState({ icon: iconFromPhase(this.store.phase) });
};
public render(): React.ReactNode {
return (
<BaseDialog
headerImage={this.state.icon}
onFinished={this.props.onFinished}
title={_t("encryption|verify_toast_title")}
>
<SetupEncryptionBody onFinished={this.props.onFinished} />
</BaseDialog>
);
}
}

View file

@ -0,0 +1,13 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021-2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
export enum Filter {
People,
PublicRooms,
PublicSpaces,
}

View file

@ -0,0 +1,46 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import React, { ReactNode, RefObject } from "react";
import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
interface OptionProps {
inputRef?: RefObject<HTMLLIElement>;
endAdornment?: ReactNode;
id?: string;
className?: string;
onClick: ((ev: ButtonEvent) => void) | null;
children?: ReactNode;
}
export const Option: React.FC<OptionProps> = ({ inputRef, children, endAdornment, className, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLLIElement>(inputRef);
return (
<AccessibleButton
{...props}
className={classNames(className, "mx_SpotlightDialog_option")}
onFocus={onFocus}
ref={ref}
tabIndex={-1}
aria-selected={isActive}
role="option"
element="li"
>
{children}
<div className="mx_SpotlightDialog_option--endAdornment">
<kbd className="mx_SpotlightDialog_enterPrompt" aria-hidden>
</kbd>
{endAdornment}
</div>
</AccessibleButton>
);
};

View file

@ -0,0 +1,71 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { IPublicRoomsChunkRoom } from "matrix-js-sdk/src/matrix";
import { linkifyAndSanitizeHtml } from "../../../../HtmlUtils";
import { _t } from "../../../../languageHandler";
import { getDisplayAliasForAliasSet } from "../../../../Rooms";
const MAX_NAME_LENGTH = 80;
const MAX_TOPIC_LENGTH = 800;
interface Props {
room: IPublicRoomsChunkRoom;
labelId: string;
descriptionId: string;
detailsId: string;
}
export function PublicRoomResultDetails({ room, labelId, descriptionId, detailsId }: Props): JSX.Element {
let name =
room.name ||
getDisplayAliasForAliasSet(room.canonical_alias ?? "", room.aliases ?? []) ||
_t("common|unnamed_room");
if (name.length > MAX_NAME_LENGTH) {
name = `${name.substring(0, MAX_NAME_LENGTH)}...`;
}
let topic = room.topic || "";
// Additional truncation based on line numbers is done via CSS,
// but to ensure that the DOM is not polluted with a huge string
// we give it a hard limit before rendering.
if (topic.length > MAX_TOPIC_LENGTH) {
topic = `${topic.substring(0, MAX_TOPIC_LENGTH)}...`;
}
return (
<div className="mx_SpotlightDialog_result_publicRoomDetails">
<div className="mx_SpotlightDialog_result_publicRoomHeader">
<span id={labelId} className="mx_SpotlightDialog_result_publicRoomName">
{name}
</span>
<span id={descriptionId} className="mx_SpotlightDialog_result_publicRoomAlias">
{room.canonical_alias ?? room.room_id}
</span>
</div>
<div id={detailsId} className="mx_SpotlightDialog_result_publicRoomDescription">
<span className="mx_SpotlightDialog_result_publicRoomMemberCount">
{_t("spotlight_dialog|count_of_members", {
count: room.num_joined_members,
})}
</span>
{topic && (
<>
&nbsp;·&nbsp;
<span
className="mx_SpotlightDialog_result_publicRoomTopic"
dangerouslySetInnerHTML={{ __html: linkifyAndSanitizeHtml(topic) }}
/>
</>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,109 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021, 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import { Room } from "matrix-js-sdk/src/matrix";
import React, { Fragment, useState } from "react";
import { ContextMenuTooltipButton } from "../../../../accessibility/context_menu/ContextMenuTooltipButton";
import { useNotificationState } from "../../../../hooks/useRoomNotificationState";
import { _t } from "../../../../languageHandler";
import { RoomNotifState } from "../../../../RoomNotifs";
import { RoomGeneralContextMenu } from "../../context_menus/RoomGeneralContextMenu";
import { RoomNotificationContextMenu } from "../../context_menus/RoomNotificationContextMenu";
import SpaceContextMenu from "../../context_menus/SpaceContextMenu";
import { ButtonEvent } from "../../elements/AccessibleButton";
import { contextMenuBelow } from "../../rooms/RoomTile";
import { shouldShowComponent } from "../../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../../settings/UIFeature";
interface Props {
room: Room;
}
export function RoomResultContextMenus({ room }: Props): JSX.Element {
const [notificationState] = useNotificationState(room);
const [generalMenuPosition, setGeneralMenuPosition] = useState<DOMRect | null>(null);
const [notificationMenuPosition, setNotificationMenuPosition] = useState<DOMRect | null>(null);
let generalMenu: JSX.Element | undefined;
if (generalMenuPosition !== null) {
if (room.isSpaceRoom()) {
generalMenu = (
<SpaceContextMenu
{...contextMenuBelow(generalMenuPosition)}
space={room}
onFinished={() => setGeneralMenuPosition(null)}
/>
);
} else {
generalMenu = (
<RoomGeneralContextMenu
{...contextMenuBelow(generalMenuPosition)}
room={room}
onFinished={() => setGeneralMenuPosition(null)}
/>
);
}
}
let notificationMenu: JSX.Element | undefined;
if (notificationMenuPosition !== null) {
notificationMenu = (
<RoomNotificationContextMenu
{...contextMenuBelow(notificationMenuPosition)}
room={room}
onFinished={() => setNotificationMenuPosition(null)}
/>
);
}
const notificationMenuClasses = classNames("mx_SpotlightDialog_option--notifications", {
// Show bell icon for the default case too.
mx_RoomNotificationContextMenu_iconBell: notificationState === RoomNotifState.AllMessages,
mx_RoomNotificationContextMenu_iconBellDot: notificationState === RoomNotifState.AllMessagesLoud,
mx_RoomNotificationContextMenu_iconBellMentions: notificationState === RoomNotifState.MentionsOnly,
mx_RoomNotificationContextMenu_iconBellCrossed: notificationState === RoomNotifState.Mute,
});
return (
<Fragment>
{shouldShowComponent(UIComponent.RoomOptionsMenu) && (
<ContextMenuTooltipButton
className="mx_SpotlightDialog_option--menu"
onClick={(ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLElement;
setGeneralMenuPosition(target.getBoundingClientRect());
}}
title={room.isSpaceRoom() ? _t("space|context_menu|options") : _t("room|context_menu|title")}
isExpanded={generalMenuPosition !== null}
/>
)}
{!room.isSpaceRoom() && (
<ContextMenuTooltipButton
className={notificationMenuClasses}
onClick={(ev: ButtonEvent) => {
ev.preventDefault();
ev.stopPropagation();
const target = ev.target as HTMLElement;
setNotificationMenuPosition(target.getBoundingClientRect());
}}
title={_t("room_list|notification_options")}
isExpanded={notificationMenuPosition !== null}
/>
)}
{generalMenu}
{notificationMenu}
</Fragment>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,40 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import classNames from "classnames";
import React, { ReactNode } from "react";
import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
import AccessibleButton, { ButtonProps } from "../../elements/AccessibleButton";
import { Ref } from "../../../../accessibility/roving/types";
type TooltipOptionProps<T extends keyof JSX.IntrinsicElements> = ButtonProps<T> & {
endAdornment?: ReactNode;
inputRef?: Ref;
};
export const TooltipOption = <T extends keyof JSX.IntrinsicElements>({
inputRef,
className,
element,
...props
}: TooltipOptionProps<T>): JSX.Element => {
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
return (
<AccessibleButton
{...props}
className={classNames(className, "mx_SpotlightDialog_option")}
onFocus={onFocus}
ref={ref}
tabIndex={-1}
aria-selected={isActive}
role="option"
element={element as keyof JSX.IntrinsicElements}
/>
);
};