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:
commit
f0ee7f7905
3265 changed files with 484599 additions and 699 deletions
62
src/components/views/dialogs/AddExistingSubspaceDialog.tsx
Normal file
62
src/components/views/dialogs/AddExistingSubspaceDialog.tsx
Normal 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;
|
512
src/components/views/dialogs/AddExistingToSpaceDialog.tsx
Normal file
512
src/components/views/dialogs/AddExistingToSpaceDialog.tsx
Normal 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;
|
106
src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx
Normal file
106
src/components/views/dialogs/AnalyticsLearnMoreDialog.tsx
Normal 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",
|
||||
);
|
||||
};
|
136
src/components/views/dialogs/AppDownloadDialog.tsx
Normal file
136
src/components/views/dialogs/AppDownloadDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
90
src/components/views/dialogs/AskInviteAnywayDialog.tsx
Normal file
90
src/components/views/dialogs/AskInviteAnywayDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
187
src/components/views/dialogs/BaseDialog.tsx
Normal file
187
src/components/views/dialogs/BaseDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
58
src/components/views/dialogs/BetaFeedbackDialog.tsx
Normal file
58
src/components/views/dialogs/BetaFeedbackDialog.tsx
Normal 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;
|
281
src/components/views/dialogs/BugReportDialog.tsx
Normal file
281
src/components/views/dialogs/BugReportDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
131
src/components/views/dialogs/BulkRedactDialog.tsx
Normal file
131
src/components/views/dialogs/BulkRedactDialog.tsx
Normal 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;
|
|
@ -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,
|
||||
});
|
||||
};
|
123
src/components/views/dialogs/ChangelogDialog.tsx
Normal file
123
src/components/views/dialogs/ChangelogDialog.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
97
src/components/views/dialogs/ConfirmAndWaitRedactDialog.tsx
Normal file
97
src/components/views/dialogs/ConfirmAndWaitRedactDialog.tsx
Normal 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} />;
|
||||
}
|
||||
}
|
||||
}
|
108
src/components/views/dialogs/ConfirmRedactDialog.tsx
Normal file
108
src/components/views/dialogs/ConfirmRedactDialog.tsx
Normal 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",
|
||||
);
|
||||
}
|
|
@ -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;
|
133
src/components/views/dialogs/ConfirmUserActionDialog.tsx
Normal file
133
src/components/views/dialogs/ConfirmUserActionDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
49
src/components/views/dialogs/ConfirmWipeDeviceDialog.tsx
Normal file
49
src/components/views/dialogs/ConfirmWipeDeviceDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
454
src/components/views/dialogs/CreateRoomDialog.tsx
Normal file
454
src/components/views/dialogs/CreateRoomDialog.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
)}
|
||||
|
||||
{_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>
|
||||
),
|
||||
},
|
||||
)}
|
||||
|
||||
{_t("create_room|join_rule_change_notice")}
|
||||
</p>
|
||||
);
|
||||
} else if (this.state.joinRule === JoinRule.Public) {
|
||||
publicPrivateLabel = (
|
||||
<p>
|
||||
{_t("create_room|join_rule_public_label")}
|
||||
|
||||
{_t("create_room|join_rule_change_notice")}
|
||||
</p>
|
||||
);
|
||||
} else if (this.state.joinRule === JoinRule.Invite) {
|
||||
publicPrivateLabel = (
|
||||
<p>
|
||||
{_t("create_room|join_rule_invite_label")}
|
||||
|
||||
{_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>
|
||||
);
|
||||
}
|
||||
}
|
194
src/components/views/dialogs/CreateSubspaceDialog.tsx
Normal file
194
src/components/views/dialogs/CreateSubspaceDialog.tsx
Normal 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;
|
231
src/components/views/dialogs/DeactivateAccountDialog.tsx
Normal file
231
src/components/views/dialogs/DeactivateAccountDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
143
src/components/views/dialogs/DevtoolsDialog.tsx
Normal file
143
src/components/views/dialogs/DevtoolsDialog.tsx
Normal 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;
|
72
src/components/views/dialogs/EndPollDialog.tsx
Normal file
72
src/components/views/dialogs/EndPollDialog.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
84
src/components/views/dialogs/ErrorDialog.tsx
Normal file
84
src/components/views/dialogs/ErrorDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
415
src/components/views/dialogs/ExportDialog.tsx
Normal file
415
src/components/views/dialogs/ExportDialog.tsx
Normal 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;
|
154
src/components/views/dialogs/FeedbackDialog.tsx
Normal file
154
src/components/views/dialogs/FeedbackDialog.tsx
Normal 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;
|
405
src/components/views/dialogs/ForwardDialog.tsx
Normal file
405
src/components/views/dialogs/ForwardDialog.tsx
Normal 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;
|
|
@ -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}
|
||||
|
||||
{_t("feedback|platform_username")}
|
||||
|
||||
{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;
|
259
src/components/views/dialogs/IncomingSasDialog.tsx
Normal file
259
src/components/views/dialogs/IncomingSasDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
65
src/components/views/dialogs/InfoDialog.tsx
Normal file
65
src/components/views/dialogs/InfoDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
55
src/components/views/dialogs/IntegrationsDisabledDialog.tsx
Normal file
55
src/components/views/dialogs/IntegrationsDisabledDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
201
src/components/views/dialogs/InteractiveAuthDialog.tsx
Normal file
201
src/components/views/dialogs/InteractiveAuthDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
1547
src/components/views/dialogs/InviteDialog.tsx
Normal file
1547
src/components/views/dialogs/InviteDialog.tsx
Normal file
File diff suppressed because it is too large
Load diff
16
src/components/views/dialogs/InviteDialogTypes.ts
Normal file
16
src/components/views/dialogs/InviteDialogTypes.ts
Normal 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",
|
||||
}
|
114
src/components/views/dialogs/LeaveSpaceDialog.tsx
Normal file
114
src/components/views/dialogs/LeaveSpaceDialog.tsx
Normal 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>,
|
||||
},
|
||||
)}
|
||||
|
||||
{rejoinWarning}
|
||||
{rejoinWarning && <> </>}
|
||||
{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;
|
265
src/components/views/dialogs/LogoutDialog.tsx
Normal file
265
src/components/views/dialogs/LogoutDialog.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
241
src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx
Normal file
241
src/components/views/dialogs/ManageRestrictedJoinRuleDialog.tsx
Normal 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;
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
187
src/components/views/dialogs/MessageEditHistoryDialog.tsx
Normal file
187
src/components/views/dialogs/MessageEditHistoryDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
211
src/components/views/dialogs/ModalWidgetDialog.tsx
Normal file
211
src/components/views/dialogs/ModalWidgetDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
79
src/components/views/dialogs/ModuleUiDialog.tsx
Normal file
79
src/components/views/dialogs/ModuleUiDialog.tsx
Normal 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>;
|
||||
}
|
||||
}
|
40
src/components/views/dialogs/PollHistoryDialog.tsx
Normal file
40
src/components/views/dialogs/PollHistoryDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
85
src/components/views/dialogs/QuestionDialog.tsx
Normal file
85
src/components/views/dialogs/QuestionDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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;
|
470
src/components/views/dialogs/ReportEventDialog.tsx
Normal file
470
src/components/views/dialogs/ReportEventDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
261
src/components/views/dialogs/RoomSettingsDialog.tsx
Normal file
261
src/components/views/dialogs/RoomSettingsDialog.tsx
Normal 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;
|
99
src/components/views/dialogs/RoomUpgradeDialog.tsx
Normal file
99
src/components/views/dialogs/RoomUpgradeDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
205
src/components/views/dialogs/RoomUpgradeWarningDialog.tsx
Normal file
205
src/components/views/dialogs/RoomUpgradeWarningDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
117
src/components/views/dialogs/ScrollableBaseModal.tsx
Normal file
117
src/components/views/dialogs/ScrollableBaseModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
118
src/components/views/dialogs/ServerOfflineDialog.tsx
Normal file
118
src/components/views/dialogs/ServerOfflineDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
249
src/components/views/dialogs/ServerPickerDialog.tsx
Normal file
249
src/components/views/dialogs/ServerPickerDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
44
src/components/views/dialogs/SeshatResetDialog.tsx
Normal file
44
src/components/views/dialogs/SeshatResetDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
102
src/components/views/dialogs/SessionRestoreErrorDialog.tsx
Normal file
102
src/components/views/dialogs/SessionRestoreErrorDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
172
src/components/views/dialogs/SetEmailDialog.tsx
Normal file
172
src/components/views/dialogs/SetEmailDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
235
src/components/views/dialogs/ShareDialog.tsx
Normal file
235
src/components/views/dialogs/ShareDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
71
src/components/views/dialogs/SlashCommandHelpDialog.tsx
Normal file
71
src/components/views/dialogs/SlashCommandHelpDialog.tsx
Normal 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;
|
90
src/components/views/dialogs/SpacePreferencesDialog.tsx
Normal file
90
src/components/views/dialogs/SpacePreferencesDialog.tsx
Normal 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;
|
94
src/components/views/dialogs/SpaceSettingsDialog.tsx
Normal file
94
src/components/views/dialogs/SpaceSettingsDialog.tsx
Normal 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;
|
71
src/components/views/dialogs/StorageEvictedDialog.tsx
Normal file
71
src/components/views/dialogs/StorageEvictedDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
217
src/components/views/dialogs/TermsDialog.tsx
Normal file
217
src/components/views/dialogs/TermsDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
139
src/components/views/dialogs/TextInputDialog.tsx
Normal file
139
src/components/views/dialogs/TextInputDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
72
src/components/views/dialogs/UnpinAllDialog.tsx
Normal file
72
src/components/views/dialogs/UnpinAllDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
73
src/components/views/dialogs/UntrustedDeviceDialog.tsx
Normal file
73
src/components/views/dialogs/UntrustedDeviceDialog.tsx
Normal 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;
|
128
src/components/views/dialogs/UploadConfirmDialog.tsx
Normal file
128
src/components/views/dialogs/UploadConfirmDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
117
src/components/views/dialogs/UploadFailureDialog.tsx
Normal file
117
src/components/views/dialogs/UploadFailureDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
249
src/components/views/dialogs/UserSettingsDialog.tsx
Normal file
249
src/components/views/dialogs/UserSettingsDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
22
src/components/views/dialogs/UserTab.ts
Normal file
22
src/components/views/dialogs/UserTab.ts
Normal 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",
|
||||
}
|
69
src/components/views/dialogs/VerificationRequestDialog.tsx
Normal file
69
src/components/views/dialogs/VerificationRequestDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
140
src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx
Normal file
140
src/components/views/dialogs/WidgetCapabilitiesPromptDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
112
src/components/views/dialogs/devtools/AccountData.tsx
Normal file
112
src/components/views/dialogs/devtools/AccountData.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
86
src/components/views/dialogs/devtools/BaseTool.tsx
Normal file
86
src/components/views/dialogs/devtools/BaseTool.tsx
Normal 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);
|
209
src/components/views/dialogs/devtools/Event.tsx
Normal file
209
src/components/views/dialogs/devtools/Event.tsx
Normal 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} />;
|
||||
};
|
88
src/components/views/dialogs/devtools/FilteredList.tsx
Normal file
88
src/components/views/dialogs/devtools/FilteredList.tsx
Normal 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;
|
220
src/components/views/dialogs/devtools/RoomNotifications.tsx
Normal file
220
src/components/views/dialogs/devtools/RoomNotifications.tsx
Normal 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;
|
||||
}
|
187
src/components/views/dialogs/devtools/RoomState.tsx
Normal file
187
src/components/views/dialogs/devtools/RoomState.tsx
Normal 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>
|
||||
);
|
||||
};
|
95
src/components/views/dialogs/devtools/ServerInfo.tsx
Normal file
95
src/components/views/dialogs/devtools/ServerInfo.tsx
Normal 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;
|
52
src/components/views/dialogs/devtools/ServersInRoom.tsx
Normal file
52
src/components/views/dialogs/devtools/ServersInRoom.tsx
Normal 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;
|
320
src/components/views/dialogs/devtools/SettingExplorer.tsx
Normal file
320
src/components/views/dialogs/devtools/SettingExplorer.tsx
Normal 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")}
|
||||
<code>{renderSettingValue(SettingsStore.getValue(setting))}</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{_t("devtools|value_this_room_colon")}
|
||||
<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>
|
||||
);
|
||||
};
|
|
@ -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;
|
65
src/components/views/dialogs/devtools/WidgetExplorer.tsx
Normal file
65
src/components/views/dialogs/devtools/WidgetExplorer.tsx
Normal 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;
|
66
src/components/views/dialogs/oidc/OidcLogoutDialog.tsx
Normal file
66
src/components/views/dialogs/oidc/OidcLogoutDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
515
src/components/views/dialogs/security/RestoreKeyBackupDialog.tsx
Normal file
515
src/components/views/dialogs/security/RestoreKeyBackupDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
13
src/components/views/dialogs/spotlight/Filter.ts
Normal file
13
src/components/views/dialogs/spotlight/Filter.ts
Normal 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,
|
||||
}
|
46
src/components/views/dialogs/spotlight/Option.tsx
Normal file
46
src/components/views/dialogs/spotlight/Option.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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 && (
|
||||
<>
|
||||
·
|
||||
<span
|
||||
className="mx_SpotlightDialog_result_publicRoomTopic"
|
||||
dangerouslySetInnerHTML={{ __html: linkifyAndSanitizeHtml(topic) }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
1299
src/components/views/dialogs/spotlight/SpotlightDialog.tsx
Normal file
1299
src/components/views/dialogs/spotlight/SpotlightDialog.tsx
Normal file
File diff suppressed because it is too large
Load diff
40
src/components/views/dialogs/spotlight/TooltipOption.tsx
Normal file
40
src/components/views/dialogs/spotlight/TooltipOption.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue