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
149
src/components/views/right_panel/BaseCard.tsx
Normal file
149
src/components/views/right_panel/BaseCard.tsx
Normal file
|
@ -0,0 +1,149 @@
|
|||
/*
|
||||
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, { forwardRef, ReactNode, KeyboardEvent, Ref, MouseEvent } from "react";
|
||||
import classNames from "classnames";
|
||||
import { IconButton, Text } from "@vector-im/compound-web";
|
||||
import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
|
||||
import ChevronLeftIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-left";
|
||||
|
||||
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
||||
import { backLabelForPhase } from "../../../stores/right-panel/RightPanelStorePhases";
|
||||
import { CardContext } from "./context";
|
||||
|
||||
interface IProps {
|
||||
header?: ReactNode | null;
|
||||
hideHeaderButtons?: boolean;
|
||||
footer?: ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
role?: "tabpanel";
|
||||
ariaLabelledBy?: string;
|
||||
withoutScrollContainer?: boolean;
|
||||
closeLabel?: string;
|
||||
onClose?(ev: MouseEvent<HTMLButtonElement>): void;
|
||||
onBack?(ev: MouseEvent<HTMLButtonElement>): void;
|
||||
onKeyDown?(ev: KeyboardEvent): void;
|
||||
cardState?: any;
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
// Ref for the 'close' button the card
|
||||
closeButtonRef?: Ref<HTMLButtonElement>;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function closeRightPanel(ev: MouseEvent<HTMLButtonElement>): void {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
RightPanelStore.instance.popCard();
|
||||
}
|
||||
|
||||
const BaseCard: React.FC<IProps> = forwardRef<HTMLDivElement, IProps>(
|
||||
(
|
||||
{
|
||||
closeLabel,
|
||||
onClose,
|
||||
onBack,
|
||||
className,
|
||||
id,
|
||||
ariaLabelledBy,
|
||||
role,
|
||||
hideHeaderButtons,
|
||||
header,
|
||||
footer,
|
||||
withoutScrollContainer,
|
||||
children,
|
||||
onKeyDown,
|
||||
closeButtonRef,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
let backButton;
|
||||
const cardHistory = RightPanelStore.instance.roomPhaseHistory;
|
||||
if (cardHistory.length > 1 && !hideHeaderButtons) {
|
||||
const prevCard = cardHistory[cardHistory.length - 2];
|
||||
const onBackClick = (ev: MouseEvent<HTMLButtonElement>): void => {
|
||||
onBack?.(ev);
|
||||
RightPanelStore.instance.popCard();
|
||||
};
|
||||
const label = backLabelForPhase(prevCard.phase) ?? _t("action|back");
|
||||
backButton = (
|
||||
<IconButton
|
||||
size="28px"
|
||||
data-testid="base-card-back-button"
|
||||
onClick={onBackClick}
|
||||
tooltip={label}
|
||||
subtleBackground
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
let closeButton;
|
||||
if (!hideHeaderButtons) {
|
||||
closeButton = (
|
||||
<IconButton
|
||||
size="28px"
|
||||
data-testid="base-card-close-button"
|
||||
onClick={onClose ?? closeRightPanel}
|
||||
ref={closeButtonRef}
|
||||
tooltip={closeLabel ?? _t("action|close")}
|
||||
subtleBackground
|
||||
>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
if (!withoutScrollContainer) {
|
||||
children = <AutoHideScrollbar>{children}</AutoHideScrollbar>;
|
||||
}
|
||||
|
||||
const shouldRenderHeader = header || !hideHeaderButtons;
|
||||
|
||||
return (
|
||||
<CardContext.Provider value={{ isCard: true }}>
|
||||
<div
|
||||
id={id}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
role={role}
|
||||
className={classNames("mx_BaseCard", className)}
|
||||
ref={ref}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{shouldRenderHeader && (
|
||||
<div className="mx_BaseCard_header">
|
||||
{backButton}
|
||||
{typeof header === "string" ? (
|
||||
<div className="mx_BaseCard_header_title">
|
||||
<Text
|
||||
size="md"
|
||||
weight="medium"
|
||||
className="mx_BaseCard_header_title_heading"
|
||||
role="heading"
|
||||
>
|
||||
{header}
|
||||
</Text>
|
||||
</div>
|
||||
) : (
|
||||
(header ?? <div className="mx_BaseCard_header_spacer" />)
|
||||
)}
|
||||
{closeButton}
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
{footer && <div className="mx_BaseCard_footer">{footer}</div>}
|
||||
</div>
|
||||
</CardContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default BaseCard;
|
34
src/components/views/right_panel/EmptyState.tsx
Normal file
34
src/components/views/right_panel/EmptyState.tsx
Normal file
|
@ -0,0 +1,34 @@
|
|||
/*
|
||||
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, { ComponentType } from "react";
|
||||
import { Text } from "@vector-im/compound-web";
|
||||
|
||||
import { Flex } from "../../utils/Flex";
|
||||
|
||||
interface Props {
|
||||
Icon: ComponentType<React.SVGAttributes<SVGElement>>;
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const EmptyState: React.FC<Props> = ({ Icon, title, description }) => {
|
||||
return (
|
||||
<Flex className="mx_EmptyState" direction="column" gap="var(--cpd-space-4x)" align="center" justify="center">
|
||||
<Icon width="32px" height="32px" />
|
||||
<Text size="lg" weight="semibold">
|
||||
{title}
|
||||
</Text>
|
||||
<Text size="md" weight="regular">
|
||||
{description}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmptyState;
|
108
src/components/views/right_panel/EncryptionInfo.tsx
Normal file
108
src/components/views/right_panel/EncryptionInfo.tsx
Normal file
|
@ -0,0 +1,108 @@
|
|||
/*
|
||||
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 { RoomMember, User } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import Spinner from "../elements/Spinner";
|
||||
|
||||
export const PendingActionSpinner: React.FC<{ text: string }> = ({ text }) => {
|
||||
return (
|
||||
<div className="mx_EncryptionInfo_spinner">
|
||||
<Spinner />
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
waitingForOtherParty: boolean;
|
||||
waitingForNetwork: boolean;
|
||||
member: RoomMember | User;
|
||||
onStartVerification: () => Promise<void>;
|
||||
isRoomEncrypted: boolean;
|
||||
inDialog: boolean;
|
||||
isSelfVerification: boolean;
|
||||
}
|
||||
|
||||
const EncryptionInfo: React.FC<IProps> = ({
|
||||
waitingForOtherParty,
|
||||
waitingForNetwork,
|
||||
member,
|
||||
onStartVerification,
|
||||
isRoomEncrypted,
|
||||
inDialog,
|
||||
isSelfVerification,
|
||||
}: IProps) => {
|
||||
let content: JSX.Element;
|
||||
if (waitingForOtherParty && isSelfVerification) {
|
||||
content = <div>{_t("encryption|verification|self_verification_hint")}</div>;
|
||||
} else if (waitingForOtherParty || waitingForNetwork) {
|
||||
let text: string;
|
||||
if (waitingForOtherParty) {
|
||||
text = _t("encryption|verification|waiting_for_user_accept", {
|
||||
displayName: (member as User).displayName || (member as RoomMember).name || member.userId,
|
||||
});
|
||||
} else {
|
||||
text = _t("encryption|verification|accepting");
|
||||
}
|
||||
content = <PendingActionSpinner text={text} />;
|
||||
} else {
|
||||
content = (
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
className="mx_UserInfo_wideButton mx_UserInfo_startVerification"
|
||||
onClick={onStartVerification}
|
||||
>
|
||||
{_t("encryption|verification|start_button")}
|
||||
</AccessibleButton>
|
||||
);
|
||||
}
|
||||
|
||||
let description: JSX.Element;
|
||||
if (isRoomEncrypted) {
|
||||
description = (
|
||||
<div>
|
||||
<p>{_t("user_info|room_encrypted")}</p>
|
||||
<p>{_t("user_info|room_encrypted_detail")}</p>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
description = (
|
||||
<div>
|
||||
<p>{_t("user_info|room_unencrypted")}</p>
|
||||
<p>{_t("user_info|room_unencrypted_detail")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (inDialog) {
|
||||
return content;
|
||||
}
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
<div data-testid="encryption-info-description" className="mx_UserInfo_container">
|
||||
<h3>{_t("settings|security|encryption_section")}</h3>
|
||||
{description}
|
||||
</div>
|
||||
<div className="mx_UserInfo_container">
|
||||
<h3>{_t("user_info|verify_button")}</h3>
|
||||
<div>
|
||||
<p>{_t("user_info|verify_explainer")}</p>
|
||||
<p>{_t("encryption|verification|in_person")}</p>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
export default EncryptionInfo;
|
174
src/components/views/right_panel/EncryptionPanel.tsx
Normal file
174
src/components/views/right_panel/EncryptionPanel.tsx
Normal file
|
@ -0,0 +1,174 @@
|
|||
/*
|
||||
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, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { VerificationPhase, VerificationRequest, VerificationRequestEvent } from "matrix-js-sdk/src/crypto-api";
|
||||
import { RoomMember, User } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import EncryptionInfo from "./EncryptionInfo";
|
||||
import VerificationPanel from "./VerificationPanel";
|
||||
import { ensureDMExists } from "../../../createRoom";
|
||||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
import Modal from "../../../Modal";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
||||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
||||
import ErrorDialog from "../dialogs/ErrorDialog";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
|
||||
// cancellation codes which constitute a key mismatch
|
||||
const MISMATCHES = ["m.key_mismatch", "m.user_error", "m.mismatched_sas"];
|
||||
|
||||
interface IProps {
|
||||
member: RoomMember | User;
|
||||
onClose: () => void;
|
||||
verificationRequest?: VerificationRequest;
|
||||
verificationRequestPromise?: Promise<VerificationRequest>;
|
||||
layout: string;
|
||||
isRoomEncrypted: boolean;
|
||||
}
|
||||
|
||||
const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
|
||||
const cli = useMatrixClientContext();
|
||||
const { verificationRequest, verificationRequestPromise, member, onClose, layout, isRoomEncrypted } = props;
|
||||
const [request, setRequest] = useState(verificationRequest);
|
||||
// state to show a spinner immediately after clicking "start verification",
|
||||
// before we have a request
|
||||
const [isRequesting, setRequesting] = useState(false);
|
||||
const [phase, doSetPhase] = useState(request?.phase);
|
||||
const setPhase = (phase: VerificationPhase | undefined): void => {
|
||||
logger.debug(`EncryptionPanel: phase now ${phase === undefined ? phase : VerificationPhase[phase]}`);
|
||||
doSetPhase(phase);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setRequest(verificationRequest);
|
||||
if (verificationRequest) {
|
||||
setRequesting(false);
|
||||
setPhase(verificationRequest.phase);
|
||||
}
|
||||
}, [verificationRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
async function awaitPromise(): Promise<void> {
|
||||
setRequesting(true);
|
||||
const requestFromPromise = await verificationRequestPromise;
|
||||
setRequesting(false);
|
||||
setRequest(requestFromPromise);
|
||||
setPhase(requestFromPromise?.phase);
|
||||
}
|
||||
if (verificationRequestPromise) {
|
||||
awaitPromise();
|
||||
}
|
||||
}, [verificationRequestPromise]);
|
||||
// Use a ref to track whether we are already showing the mismatch modal as state may not update fast enough
|
||||
// if two change events are fired in quick succession like can happen with rust crypto.
|
||||
const isShowingMismatchModal = useRef(false);
|
||||
const changeHandler = useCallback(() => {
|
||||
// handle transitions -> cancelled for mismatches which fire a modal instead of showing a card
|
||||
if (
|
||||
!isShowingMismatchModal.current &&
|
||||
request?.phase === VerificationPhase.Cancelled &&
|
||||
MISMATCHES.includes(request.cancellationCode ?? "")
|
||||
) {
|
||||
isShowingMismatchModal.current = true;
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
headerImage: require("../../../../res/img/e2e/warning-deprecated.svg").default,
|
||||
title: _t("encryption|messages_not_secure|title"),
|
||||
description: (
|
||||
<div>
|
||||
{_t("encryption|messages_not_secure|heading")}
|
||||
<ul>
|
||||
<li>{_t("encryption|messages_not_secure|cause_1")}</li>
|
||||
<li>{_t("encryption|messages_not_secure|cause_2")}</li>
|
||||
<li>{_t("encryption|messages_not_secure|cause_3")}</li>
|
||||
<li>{_t("encryption|messages_not_secure|cause_4")}</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
onFinished: onClose,
|
||||
});
|
||||
return; // don't update phase here as we will be transitioning away from this view shortly
|
||||
}
|
||||
|
||||
if (request) {
|
||||
setPhase(request.phase);
|
||||
}
|
||||
}, [onClose, request]);
|
||||
|
||||
useTypedEventEmitter(request, VerificationRequestEvent.Change, changeHandler);
|
||||
|
||||
const onStartVerification = useCallback(async (): Promise<void> => {
|
||||
setRequesting(true);
|
||||
let verificationRequest_: VerificationRequest;
|
||||
try {
|
||||
const roomId = await ensureDMExists(cli, member.userId);
|
||||
if (!roomId) {
|
||||
throw new Error("Unable to create Room for verification");
|
||||
}
|
||||
verificationRequest_ = await cli.getCrypto()!.requestVerificationDM(member.userId, roomId);
|
||||
} catch (e) {
|
||||
console.error("Error starting verification", e);
|
||||
setRequesting(false);
|
||||
|
||||
Modal.createDialog(ErrorDialog, {
|
||||
headerImage: require("../../../../res/img/e2e/warning.svg").default,
|
||||
title: _t("encryption|verification|error_starting_title"),
|
||||
description: _t("encryption|verification|error_starting_description"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
setRequest(verificationRequest_);
|
||||
setPhase(verificationRequest_.phase);
|
||||
// Notify the RightPanelStore about this
|
||||
if (RightPanelStore.instance.currentCard.phase != RightPanelPhases.EncryptionPanel) {
|
||||
RightPanelStore.instance.pushCard({
|
||||
phase: RightPanelPhases.EncryptionPanel,
|
||||
state: { member, verificationRequest: verificationRequest_ },
|
||||
});
|
||||
}
|
||||
if (!RightPanelStore.instance.isOpen) RightPanelStore.instance.togglePanel(null);
|
||||
}, [cli, member]);
|
||||
|
||||
const requested: boolean =
|
||||
(!request && isRequesting) ||
|
||||
(!!request &&
|
||||
(phase === VerificationPhase.Requested || phase === VerificationPhase.Unsent || phase === undefined));
|
||||
const isSelfVerification = request ? request.isSelfVerification : member.userId === cli.getUserId();
|
||||
|
||||
if (!request || requested) {
|
||||
const initiatedByMe = (!request && isRequesting) || (!!request && request.initiatedByMe);
|
||||
return (
|
||||
<EncryptionInfo
|
||||
isRoomEncrypted={isRoomEncrypted}
|
||||
onStartVerification={onStartVerification}
|
||||
member={member}
|
||||
isSelfVerification={isSelfVerification}
|
||||
waitingForOtherParty={requested && initiatedByMe}
|
||||
waitingForNetwork={requested && !initiatedByMe}
|
||||
inDialog={layout === "dialog"}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<VerificationPanel
|
||||
isRoomEncrypted={isRoomEncrypted}
|
||||
layout={layout}
|
||||
onClose={onClose}
|
||||
member={member}
|
||||
request={request}
|
||||
key={request.transactionId}
|
||||
inDialog={layout === "dialog"}
|
||||
phase={phase}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default EncryptionPanel;
|
202
src/components/views/right_panel/ExtensionsCard.tsx
Normal file
202
src/components/views/right_panel/ExtensionsCard.tsx
Normal file
|
@ -0,0 +1,202 @@
|
|||
/*
|
||||
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, { useEffect, useMemo, useState } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/matrix";
|
||||
import classNames from "classnames";
|
||||
import { Button, Link, Separator, Text } from "@vector-im/compound-web";
|
||||
import PlusIcon from "@vector-im/compound-design-tokens/assets/web/icons/plus";
|
||||
import ExtensionsIcon from "@vector-im/compound-design-tokens/assets/web/icons/extensions";
|
||||
|
||||
import BaseCard from "./BaseCard";
|
||||
import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
|
||||
import { WidgetContextMenu } from "../context_menus/WidgetContextMenu";
|
||||
import UIStore from "../../../stores/UIStore";
|
||||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
||||
import { IApp } from "../../../stores/WidgetStore";
|
||||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
||||
import { Container, MAX_PINNED, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import WidgetAvatar from "../avatars/WidgetAvatar";
|
||||
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
|
||||
import EmptyState from "./EmptyState";
|
||||
|
||||
interface Props {
|
||||
room: Room;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
interface IAppRowProps {
|
||||
app: IApp;
|
||||
room: Room;
|
||||
}
|
||||
|
||||
const AppRow: React.FC<IAppRowProps> = ({ app, room }) => {
|
||||
const name = WidgetUtils.getWidgetName(app);
|
||||
const [canModifyWidget, setCanModifyWidget] = useState<boolean>();
|
||||
|
||||
useEffect(() => {
|
||||
setCanModifyWidget(WidgetUtils.canUserModifyWidgets(room.client, room.roomId));
|
||||
}, [room.client, room.roomId]);
|
||||
|
||||
const onOpenWidgetClick = (): void => {
|
||||
RightPanelStore.instance.pushCard({
|
||||
phase: RightPanelPhases.Widget,
|
||||
state: { widgetId: app.id },
|
||||
});
|
||||
};
|
||||
|
||||
const isPinned = WidgetLayoutStore.instance.isInContainer(room, app, Container.Top);
|
||||
const togglePin = isPinned
|
||||
? () => {
|
||||
WidgetLayoutStore.instance.moveToContainer(room, app, Container.Right);
|
||||
}
|
||||
: () => {
|
||||
WidgetLayoutStore.instance.moveToContainer(room, app, Container.Top);
|
||||
};
|
||||
|
||||
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLDivElement>();
|
||||
let contextMenu;
|
||||
if (menuDisplayed) {
|
||||
const rect = handle.current?.getBoundingClientRect();
|
||||
const rightMargin = rect?.right ?? 0;
|
||||
const topMargin = rect?.top ?? 0;
|
||||
contextMenu = (
|
||||
<WidgetContextMenu
|
||||
chevronFace={ChevronFace.None}
|
||||
right={UIStore.instance.windowWidth - rightMargin}
|
||||
bottom={UIStore.instance.windowHeight - topMargin}
|
||||
onFinished={closeMenu}
|
||||
app={app}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const cannotPin = !isPinned && !WidgetLayoutStore.instance.canAddToContainer(room, Container.Top);
|
||||
|
||||
let pinTitle: string;
|
||||
if (cannotPin) {
|
||||
pinTitle = _t("right_panel|pinned_messages|limits", { count: MAX_PINNED });
|
||||
} else {
|
||||
pinTitle = isPinned ? _t("action|unpin") : _t("action|pin");
|
||||
}
|
||||
|
||||
const isMaximised = WidgetLayoutStore.instance.isInContainer(room, app, Container.Center);
|
||||
|
||||
let openTitle = "";
|
||||
if (isPinned) {
|
||||
openTitle = _t("widget|unpin_to_view_right_panel");
|
||||
} else if (isMaximised) {
|
||||
openTitle = _t("widget|close_to_view_right_panel");
|
||||
}
|
||||
|
||||
const classes = classNames("mx_BaseCard_Button mx_ExtensionsCard_Button", {
|
||||
mx_ExtensionsCard_Button_pinned: isPinned,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={classes} ref={handle}>
|
||||
<AccessibleButton
|
||||
className="mx_ExtensionsCard_icon_app"
|
||||
onClick={onOpenWidgetClick}
|
||||
// only show a tooltip if the widget is pinned
|
||||
title={!(isPinned || isMaximised) ? undefined : openTitle}
|
||||
disabled={isPinned || isMaximised}
|
||||
>
|
||||
<WidgetAvatar app={app} size="24px" />
|
||||
<Text size="md" weight="medium" className="mx_lineClamp">
|
||||
{name}
|
||||
</Text>
|
||||
</AccessibleButton>
|
||||
|
||||
{canModifyWidget && (
|
||||
<ContextMenuTooltipButton
|
||||
className="mx_ExtensionsCard_app_options"
|
||||
isExpanded={menuDisplayed}
|
||||
onClick={openMenu}
|
||||
title={_t("common|options")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AccessibleButton
|
||||
className="mx_ExtensionsCard_app_pinToggle"
|
||||
onClick={togglePin}
|
||||
title={pinTitle}
|
||||
disabled={cannotPin}
|
||||
/>
|
||||
|
||||
{contextMenu}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* A right panel card displaying a list of widgets in the room and allowing the user to manage them.
|
||||
* @param room the room to manage widgets for
|
||||
* @param onClose callback when the card is closed
|
||||
*/
|
||||
const ExtensionsCard: React.FC<Props> = ({ room, onClose }) => {
|
||||
const apps = useWidgets(room);
|
||||
// Filter out virtual widgets
|
||||
const realApps = useMemo(() => apps.filter((app) => app.eventId !== undefined), [apps]);
|
||||
|
||||
const onManageIntegrations = (): void => {
|
||||
const managers = IntegrationManagers.sharedInstance();
|
||||
if (!managers.hasManager()) {
|
||||
managers.openNoManagerDialog();
|
||||
} else {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
managers.getPrimaryManager()?.open(room);
|
||||
}
|
||||
};
|
||||
|
||||
let body: JSX.Element;
|
||||
if (realApps.length < 1) {
|
||||
body = (
|
||||
<EmptyState
|
||||
Icon={ExtensionsIcon}
|
||||
title={_t("right_panel|extensions_empty_title")}
|
||||
description={_t("right_panel|extensions_empty_description", {
|
||||
addIntegrations: _t("right_panel|add_integrations"),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
let copyLayoutBtn: JSX.Element | null = null;
|
||||
if (WidgetLayoutStore.instance.canCopyLayoutToRoom(room)) {
|
||||
copyLayoutBtn = (
|
||||
<Link onClick={() => WidgetLayoutStore.instance.copyLayoutToRoom(room)}>
|
||||
{_t("widget|set_room_layout")}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
body = (
|
||||
<>
|
||||
<Separator />
|
||||
{realApps.map((app) => (
|
||||
<AppRow key={app.id} app={app} room={room} />
|
||||
))}
|
||||
{copyLayoutBtn}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseCard header={_t("right_panel|extensions_button")} className="mx_ExtensionsCard" onClose={onClose}>
|
||||
<Button size="sm" onClick={onManageIntegrations} kind="secondary" Icon={PlusIcon}>
|
||||
{_t("right_panel|add_integrations")}
|
||||
</Button>
|
||||
{body}
|
||||
</BaseCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExtensionsCard;
|
176
src/components/views/right_panel/PinnedMessagesCard.tsx
Normal file
176
src/components/views/right_panel/PinnedMessagesCard.tsx
Normal file
|
@ -0,0 +1,176 @@
|
|||
/*
|
||||
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, { useCallback, useEffect, JSX } from "react";
|
||||
import { Room, MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { Button, Separator } from "@vector-im/compound-web";
|
||||
import classNames from "classnames";
|
||||
import PinIcon from "@vector-im/compound-design-tokens/assets/web/icons/pin";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import BaseCard from "./BaseCard";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { PinnedEventTile } from "../rooms/PinnedEventTile";
|
||||
import { useRoomState } from "../../../hooks/useRoomState";
|
||||
import RoomContext, { TimelineRenderingType, useRoomContext } from "../../../contexts/RoomContext";
|
||||
import { ReadPinsEventId } from "./types";
|
||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import { filterBoolean } from "../../../utils/arrays";
|
||||
import Modal from "../../../Modal";
|
||||
import { UnpinAllDialog } from "../dialogs/UnpinAllDialog";
|
||||
import EmptyState from "./EmptyState";
|
||||
import { usePinnedEvents, useReadPinnedEvents, useSortedFetchedPinnedEvents } from "../../../hooks/usePinnedEvents";
|
||||
import PinningUtils from "../../../utils/PinningUtils.ts";
|
||||
|
||||
/**
|
||||
* List the pinned messages in a room inside a Card.
|
||||
*/
|
||||
interface PinnedMessagesCardProps {
|
||||
/**
|
||||
* The room to list the pinned messages for.
|
||||
*/
|
||||
room: Room;
|
||||
/**
|
||||
* Permalink of the room.
|
||||
*/
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
/**
|
||||
* Callback for when the card is closed.
|
||||
*/
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
export function PinnedMessagesCard({ room, onClose, permalinkCreator }: PinnedMessagesCardProps): JSX.Element {
|
||||
const cli = useMatrixClientContext();
|
||||
const roomContext = useRoomContext();
|
||||
const pinnedEventIds = usePinnedEvents(room);
|
||||
const readPinnedEvents = useReadPinnedEvents(room);
|
||||
const pinnedEvents = useSortedFetchedPinnedEvents(room, pinnedEventIds);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cli || cli.isGuest()) return; // nothing to do
|
||||
const newlyRead = pinnedEventIds.filter((id) => !readPinnedEvents.has(id));
|
||||
if (newlyRead.length > 0) {
|
||||
// clear out any read pinned events which no longer are pinned
|
||||
cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
|
||||
event_ids: pinnedEventIds,
|
||||
});
|
||||
}
|
||||
}, [cli, room.roomId, pinnedEventIds, readPinnedEvents]);
|
||||
|
||||
let content: JSX.Element;
|
||||
if (!pinnedEventIds.length) {
|
||||
content = (
|
||||
<EmptyState
|
||||
Icon={PinIcon}
|
||||
title={_t("right_panel|pinned_messages|empty_title")}
|
||||
description={_t("right_panel|pinned_messages|empty_description", {
|
||||
pinAction: _t("action|pin"),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
} else if (pinnedEvents?.length) {
|
||||
content = (
|
||||
<PinnedMessages events={filterBoolean(pinnedEvents)} room={room} permalinkCreator={permalinkCreator} />
|
||||
);
|
||||
} else {
|
||||
content = <Spinner />;
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseCard
|
||||
header={_t("right_panel|pinned_messages|header", { count: pinnedEventIds.length })}
|
||||
className="mx_PinnedMessagesCard"
|
||||
onClose={onClose}
|
||||
>
|
||||
<RoomContext.Provider
|
||||
value={{
|
||||
...roomContext,
|
||||
timelineRenderingType: TimelineRenderingType.Pinned,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</RoomContext.Provider>
|
||||
</BaseCard>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The pinned messages in a room.
|
||||
*/
|
||||
interface PinnedMessagesProps {
|
||||
/**
|
||||
* The pinned events.
|
||||
*/
|
||||
events: MatrixEvent[];
|
||||
/**
|
||||
* The room the events are in.
|
||||
*/
|
||||
room: Room;
|
||||
/**
|
||||
* The permalink creator to use.
|
||||
*/
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pinned messages in a room.
|
||||
*/
|
||||
function PinnedMessages({ events, room, permalinkCreator }: PinnedMessagesProps): JSX.Element {
|
||||
const matrixClient = useMatrixClientContext();
|
||||
|
||||
/**
|
||||
* Whether the client can unpin events from the room.
|
||||
* Listen to room state to update this value.
|
||||
*/
|
||||
const canUnpin = useRoomState(room, () => PinningUtils.userHasPinOrUnpinPermission(matrixClient, room));
|
||||
|
||||
/**
|
||||
* Opens the unpin all dialog.
|
||||
*/
|
||||
const onUnpinAll = useCallback(async (): Promise<void> => {
|
||||
Modal.createDialog(UnpinAllDialog, {
|
||||
roomId: room.roomId,
|
||||
matrixClient,
|
||||
});
|
||||
}, [room, matrixClient]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={classNames("mx_PinnedMessagesCard_wrapper", {
|
||||
mx_PinnedMessagesCard_wrapper_unpin_all: canUnpin,
|
||||
})}
|
||||
role="list"
|
||||
>
|
||||
{events.map((event, i) => (
|
||||
<>
|
||||
<PinnedEventTile
|
||||
key={event.getId()}
|
||||
event={event}
|
||||
permalinkCreator={permalinkCreator}
|
||||
room={room}
|
||||
/>
|
||||
{/* Add a separator if this isn't the last pinned message */}
|
||||
{events.length - 1 !== i && (
|
||||
<Separator key={`separator-${event.getId()}`} className="mx_PinnedMessagesCard_Separator" />
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
{canUnpin && (
|
||||
<div className="mx_PinnedMessagesCard_unpin">
|
||||
<Button kind="tertiary" onClick={onUnpinAll}>
|
||||
{_t("right_panel|pinned_messages|unpin_all|button")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
453
src/components/views/right_panel/RoomSummaryCard.tsx
Normal file
453
src/components/views/right_panel/RoomSummaryCard.tsx
Normal file
|
@ -0,0 +1,453 @@
|
|||
/*
|
||||
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, { ChangeEvent, SyntheticEvent, useContext, useEffect, useRef, useState } from "react";
|
||||
import classNames from "classnames";
|
||||
import {
|
||||
MenuItem,
|
||||
Separator,
|
||||
ToggleMenuItem,
|
||||
Text,
|
||||
Badge,
|
||||
Heading,
|
||||
IconButton,
|
||||
Link,
|
||||
Search,
|
||||
Form,
|
||||
} from "@vector-im/compound-web";
|
||||
import FavouriteIcon from "@vector-im/compound-design-tokens/assets/web/icons/favourite";
|
||||
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
|
||||
import LinkIcon from "@vector-im/compound-design-tokens/assets/web/icons/link";
|
||||
import SettingsIcon from "@vector-im/compound-design-tokens/assets/web/icons/settings";
|
||||
import ExportArchiveIcon from "@vector-im/compound-design-tokens/assets/web/icons/export-archive";
|
||||
import LeaveIcon from "@vector-im/compound-design-tokens/assets/web/icons/leave";
|
||||
import FilesIcon from "@vector-im/compound-design-tokens/assets/web/icons/files";
|
||||
import ExtensionsIcon from "@vector-im/compound-design-tokens/assets/web/icons/extensions";
|
||||
import UserProfileIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-profile";
|
||||
import ThreadsIcon from "@vector-im/compound-design-tokens/assets/web/icons/threads";
|
||||
import PollsIcon from "@vector-im/compound-design-tokens/assets/web/icons/polls";
|
||||
import PinIcon from "@vector-im/compound-design-tokens/assets/web/icons/pin";
|
||||
import LockIcon from "@vector-im/compound-design-tokens/assets/web/icons/lock-solid";
|
||||
import LockOffIcon from "@vector-im/compound-design-tokens/assets/web/icons/lock-off";
|
||||
import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public";
|
||||
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
|
||||
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
|
||||
import { EventType, JoinRule, Room, RoomStateEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { useIsEncrypted } from "../../../hooks/useIsEncrypted";
|
||||
import BaseCard from "./BaseCard";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import RoomAvatar from "../avatars/RoomAvatar";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
|
||||
import Modal from "../../../Modal";
|
||||
import ShareDialog from "../dialogs/ShareDialog";
|
||||
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||
import { E2EStatus } from "../../../utils/ShieldUtils";
|
||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
import RoomName from "../elements/RoomName";
|
||||
import ExportDialog from "../dialogs/ExportDialog";
|
||||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
||||
import PosthogTrackers from "../../../PosthogTrackers";
|
||||
import { PollHistoryDialog } from "../dialogs/PollHistoryDialog";
|
||||
import { Flex } from "../../utils/Flex";
|
||||
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
|
||||
import { DefaultTagID } from "../../../stores/room-list/models";
|
||||
import { tagRoom } from "../../../utils/room/tagRoom";
|
||||
import { canInviteTo } from "../../../utils/room/canInviteTo";
|
||||
import { inviteToRoom } from "../../../utils/room/inviteToRoom";
|
||||
import { useAccountData } from "../../../hooks/useAccountData";
|
||||
import { useRoomState } from "../../../hooks/useRoomState";
|
||||
import { useTopic } from "../../../hooks/room/useTopic";
|
||||
import { Linkify, topicToHtml } from "../../../HtmlUtils";
|
||||
import { Box } from "../../utils/Box";
|
||||
import { onRoomTopicLinkClick } from "../elements/RoomTopic";
|
||||
import { useDispatcher } from "../../../hooks/useDispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { Key } from "../../../Keyboard";
|
||||
import { useTransition } from "../../../hooks/useTransition";
|
||||
import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms";
|
||||
import { usePinnedEvents } from "../../../hooks/usePinnedEvents";
|
||||
import { ReleaseAnnouncement } from "../../structures/ReleaseAnnouncement.tsx";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
onSearchChange?: (e: ChangeEvent) => void;
|
||||
onSearchCancel?: () => void;
|
||||
focusRoomSearch?: boolean;
|
||||
}
|
||||
|
||||
const onRoomMembersClick = (): void => {
|
||||
RightPanelStore.instance.pushCard({ phase: RightPanelPhases.RoomMemberList }, true);
|
||||
};
|
||||
|
||||
const onRoomThreadsClick = (): void => {
|
||||
RightPanelStore.instance.pushCard({ phase: RightPanelPhases.ThreadPanel }, true);
|
||||
};
|
||||
|
||||
const onRoomFilesClick = (): void => {
|
||||
RightPanelStore.instance.pushCard({ phase: RightPanelPhases.FilePanel }, true);
|
||||
};
|
||||
|
||||
const onRoomExtensionsClick = (): void => {
|
||||
RightPanelStore.instance.pushCard({ phase: RightPanelPhases.Extensions }, true);
|
||||
};
|
||||
|
||||
const onRoomPinsClick = (): void => {
|
||||
PosthogTrackers.trackInteraction("PinnedMessageRoomInfoButton");
|
||||
RightPanelStore.instance.pushCard({ phase: RightPanelPhases.PinnedMessages }, true);
|
||||
};
|
||||
|
||||
const onRoomSettingsClick = (ev: Event): void => {
|
||||
defaultDispatcher.dispatch({ action: "open_room_settings" });
|
||||
PosthogTrackers.trackInteraction("WebRightPanelRoomInfoSettingsButton", ev);
|
||||
};
|
||||
|
||||
const RoomTopic: React.FC<Pick<IProps, "room">> = ({ room }): JSX.Element | null => {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
const topic = useTopic(room);
|
||||
const body = topicToHtml(topic?.text, topic?.html);
|
||||
|
||||
const canEditTopic = useRoomState(room, (state) =>
|
||||
state.maySendStateEvent(EventType.RoomTopic, room.client.getSafeUserId()),
|
||||
);
|
||||
const onEditClick = (e: SyntheticEvent): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
defaultDispatcher.dispatch({ action: "open_room_settings" });
|
||||
};
|
||||
|
||||
if (!body && !canEditTopic) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!body) {
|
||||
return (
|
||||
<Flex
|
||||
as="section"
|
||||
direction="column"
|
||||
justify="center"
|
||||
gap="var(--cpd-space-2x)"
|
||||
className="mx_RoomSummaryCard_topic"
|
||||
>
|
||||
<Box flex="1">
|
||||
<Link kind="primary" onClick={onEditClick}>
|
||||
<Text size="sm" weight="regular">
|
||||
{_t("right_panel|add_topic")}
|
||||
</Text>
|
||||
</Link>
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const content = expanded ? <Linkify>{body}</Linkify> : body;
|
||||
return (
|
||||
<Flex
|
||||
as="section"
|
||||
direction="column"
|
||||
justify="center"
|
||||
gap="var(--cpd-space-2x)"
|
||||
className={classNames("mx_RoomSummaryCard_topic", {
|
||||
mx_RoomSummaryCard_topic_collapsed: !expanded,
|
||||
})}
|
||||
>
|
||||
<Box flex="1" className="mx_RoomSummaryCard_topic_container">
|
||||
<Text
|
||||
size="sm"
|
||||
weight="regular"
|
||||
onClick={(ev: React.MouseEvent): void => {
|
||||
if (ev.target instanceof HTMLAnchorElement) {
|
||||
onRoomTopicLinkClick(ev);
|
||||
return;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Text>
|
||||
<IconButton
|
||||
className="mx_RoomSummaryCard_topic_chevron"
|
||||
size="24px"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
<ChevronDownIcon />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{expanded && canEditTopic && (
|
||||
<Box flex="1" className="mx_RoomSummaryCard_topic_edit">
|
||||
<Link kind="primary" onClick={onEditClick}>
|
||||
<Text size="sm" weight="regular">
|
||||
{_t("action|edit")}
|
||||
</Text>
|
||||
</Link>
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
const RoomSummaryCard: React.FC<IProps> = ({
|
||||
room,
|
||||
permalinkCreator,
|
||||
onSearchChange,
|
||||
onSearchCancel,
|
||||
focusRoomSearch,
|
||||
}) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const onShareRoomClick = (): void => {
|
||||
Modal.createDialog(ShareDialog, {
|
||||
target: room,
|
||||
});
|
||||
};
|
||||
|
||||
const onRoomExportClick = async (): Promise<void> => {
|
||||
Modal.createDialog(ExportDialog, {
|
||||
room,
|
||||
});
|
||||
};
|
||||
|
||||
const onRoomPollHistoryClick = (): void => {
|
||||
Modal.createDialog(PollHistoryDialog, {
|
||||
room,
|
||||
matrixClient: cli,
|
||||
permalinkCreator,
|
||||
});
|
||||
};
|
||||
|
||||
const onLeaveRoomClick = (): void => {
|
||||
defaultDispatcher.dispatch({
|
||||
action: "leave_room",
|
||||
room_id: room.roomId,
|
||||
});
|
||||
};
|
||||
|
||||
const isRoomEncrypted = useIsEncrypted(cli, room);
|
||||
const roomContext = useContext(RoomContext);
|
||||
const e2eStatus = roomContext.e2eStatus;
|
||||
const isVideoRoom = calcIsVideoRoom(room);
|
||||
|
||||
const roomState = useRoomState(room);
|
||||
const directRoomsList = useAccountData<Record<string, string[]>>(room.client, EventType.Direct);
|
||||
const [isDirectMessage, setDirectMessage] = useState(false);
|
||||
useEffect(() => {
|
||||
for (const [, dmRoomList] of Object.entries(directRoomsList)) {
|
||||
if (dmRoomList.includes(room?.roomId ?? "")) {
|
||||
setDirectMessage(true);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [room, directRoomsList]);
|
||||
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
useDispatcher(defaultDispatcher, (payload) => {
|
||||
if (payload.action === Action.FocusMessageSearch) {
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
});
|
||||
// Clear the search field when the user leaves the search view
|
||||
useTransition(
|
||||
(prevTimelineRenderingType) => {
|
||||
if (
|
||||
prevTimelineRenderingType === TimelineRenderingType.Search &&
|
||||
roomContext.timelineRenderingType !== TimelineRenderingType.Search &&
|
||||
searchInputRef.current
|
||||
) {
|
||||
searchInputRef.current.value = "";
|
||||
}
|
||||
},
|
||||
[roomContext.timelineRenderingType],
|
||||
);
|
||||
|
||||
const alias = room.getCanonicalAlias() || room.getAltAliases()[0] || "";
|
||||
const roomInfo = (
|
||||
<header className="mx_RoomSummaryCard_container">
|
||||
<RoomAvatar room={room} size="80px" viewAvatarOnClick />
|
||||
<RoomName room={room}>
|
||||
{(name) => (
|
||||
<Heading
|
||||
as="h1"
|
||||
size="md"
|
||||
weight="semibold"
|
||||
className="mx_RoomSummaryCard_roomName text-primary"
|
||||
title={name}
|
||||
>
|
||||
{name}
|
||||
</Heading>
|
||||
)}
|
||||
</RoomName>
|
||||
<Text
|
||||
as="div"
|
||||
size="sm"
|
||||
weight="semibold"
|
||||
className="mx_RoomSummaryCard_alias text-secondary"
|
||||
title={alias}
|
||||
>
|
||||
{alias}
|
||||
</Text>
|
||||
|
||||
<Flex as="section" justify="center" gap="var(--cpd-space-2x)" className="mx_RoomSummaryCard_badges">
|
||||
{!isDirectMessage && roomState.getJoinRule() === JoinRule.Public && (
|
||||
<Badge kind="grey">
|
||||
<PublicIcon width="1em" />
|
||||
{_t("common|public_room")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{isRoomEncrypted && e2eStatus !== E2EStatus.Warning && (
|
||||
<Badge kind="green">
|
||||
<LockIcon width="1em" />
|
||||
{_t("common|encrypted")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{!e2eStatus && (
|
||||
<Badge kind="grey">
|
||||
<LockOffIcon width="1em" />
|
||||
{_t("common|unencrypted")}
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{e2eStatus === E2EStatus.Warning && (
|
||||
<Badge kind="red">
|
||||
<ErrorIcon width="1em" />
|
||||
{_t("common|not_trusted")}
|
||||
</Badge>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<RoomTopic room={room} />
|
||||
</header>
|
||||
);
|
||||
|
||||
const pinCount = usePinnedEvents(room).length;
|
||||
|
||||
const roomTags = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () =>
|
||||
RoomListStore.instance.getTagsForRoom(room),
|
||||
);
|
||||
const canInviteToState = useEventEmitterState(room, RoomStateEvent.Update, () => canInviteTo(room));
|
||||
const isFavorite = roomTags.includes(DefaultTagID.Favourite);
|
||||
|
||||
const header = onSearchChange && (
|
||||
<Form.Root className="mx_RoomSummaryCard_search" onSubmit={(e) => e.preventDefault()}>
|
||||
<Search
|
||||
placeholder={_t("room|search|placeholder")}
|
||||
name="room_message_search"
|
||||
onChange={onSearchChange}
|
||||
className="mx_no_textinput"
|
||||
ref={searchInputRef}
|
||||
autoFocus={focusRoomSearch}
|
||||
onKeyDown={(e) => {
|
||||
if (searchInputRef.current && e.key === Key.ESCAPE) {
|
||||
searchInputRef.current.value = "";
|
||||
onSearchCancel?.();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Form.Root>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseCard
|
||||
id="room-summary-panel"
|
||||
className="mx_RoomSummaryCard"
|
||||
ariaLabelledBy="room-summary-panel-tab"
|
||||
role="tabpanel"
|
||||
header={header}
|
||||
>
|
||||
{roomInfo}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div role="menubar" aria-orientation="vertical">
|
||||
<ToggleMenuItem
|
||||
Icon={FavouriteIcon}
|
||||
label={_t("room|context_menu|favourite")}
|
||||
checked={isFavorite}
|
||||
onSelect={() => tagRoom(room, DefaultTagID.Favourite)}
|
||||
/>
|
||||
<MenuItem
|
||||
Icon={UserAddIcon}
|
||||
label={_t("action|invite")}
|
||||
disabled={!canInviteToState}
|
||||
onSelect={() => inviteToRoom(room)}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<MenuItem Icon={UserProfileIcon} label={_t("common|people")} onSelect={onRoomMembersClick} />
|
||||
<MenuItem Icon={ThreadsIcon} label={_t("common|threads")} onSelect={onRoomThreadsClick} />
|
||||
{!isVideoRoom && (
|
||||
<>
|
||||
<ReleaseAnnouncement
|
||||
feature="pinningMessageList"
|
||||
header={_t("right_panel|pinned_messages|release_announcement|title")}
|
||||
description={_t("right_panel|pinned_messages|release_announcement|description")}
|
||||
closeLabel={_t("right_panel|pinned_messages|release_announcement|close")}
|
||||
placement="top"
|
||||
>
|
||||
<div>
|
||||
<MenuItem
|
||||
Icon={PinIcon}
|
||||
label={_t("right_panel|pinned_messages_button")}
|
||||
onSelect={onRoomPinsClick}
|
||||
>
|
||||
<Text as="span" size="sm">
|
||||
{pinCount}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</div>
|
||||
</ReleaseAnnouncement>
|
||||
<MenuItem Icon={FilesIcon} label={_t("right_panel|files_button")} onSelect={onRoomFilesClick} />
|
||||
<MenuItem
|
||||
Icon={ExtensionsIcon}
|
||||
label={_t("right_panel|extensions_button")}
|
||||
onSelect={onRoomExtensionsClick}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<MenuItem Icon={LinkIcon} label={_t("action|copy_link")} onSelect={onShareRoomClick} />
|
||||
|
||||
{!isVideoRoom && (
|
||||
<>
|
||||
<MenuItem
|
||||
Icon={PollsIcon}
|
||||
label={_t("right_panel|polls_button")}
|
||||
onSelect={onRoomPollHistoryClick}
|
||||
/>
|
||||
<MenuItem
|
||||
Icon={ExportArchiveIcon}
|
||||
label={_t("export_chat|title")}
|
||||
onSelect={onRoomExportClick}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<MenuItem Icon={SettingsIcon} label={_t("common|settings")} onSelect={onRoomSettingsClick} />
|
||||
|
||||
<Separator />
|
||||
|
||||
<MenuItem
|
||||
Icon={LeaveIcon}
|
||||
kind="critical"
|
||||
label={_t("action|leave_room")}
|
||||
onSelect={onLeaveRoomClick}
|
||||
/>
|
||||
</div>
|
||||
</BaseCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoomSummaryCard;
|
265
src/components/views/right_panel/TimelineCard.tsx
Normal file
265
src/components/views/right_panel/TimelineCard.tsx
Normal file
|
@ -0,0 +1,265 @@
|
|||
/*
|
||||
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 React from "react";
|
||||
import {
|
||||
IEventRelation,
|
||||
MatrixEvent,
|
||||
NotificationCountType,
|
||||
Room,
|
||||
EventTimelineSet,
|
||||
Thread,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
|
||||
import BaseCard from "./BaseCard";
|
||||
import ResizeNotifier from "../../../utils/ResizeNotifier";
|
||||
import MessageComposer from "../rooms/MessageComposer";
|
||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import { Layout } from "../../../settings/enums/Layout";
|
||||
import TimelinePanel from "../../structures/TimelinePanel";
|
||||
import { E2EStatus } from "../../../utils/ShieldUtils";
|
||||
import EditorStateTransfer from "../../../utils/EditorStateTransfer";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { ActionPayload } from "../../../dispatcher/payloads";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import ContentMessages from "../../../ContentMessages";
|
||||
import UploadBar from "../../structures/UploadBar";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import JumpToBottomButton from "../rooms/JumpToBottomButton";
|
||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import Measured from "../elements/Measured";
|
||||
import { UPDATE_EVENT } from "../../../stores/AsyncStore";
|
||||
import { SdkContextClass } from "../../../contexts/SDKContext";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
onClose: () => void;
|
||||
resizeNotifier: ResizeNotifier;
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
e2eStatus?: E2EStatus;
|
||||
classNames?: string;
|
||||
timelineSet: EventTimelineSet;
|
||||
timelineRenderingType?: TimelineRenderingType;
|
||||
showComposer?: boolean;
|
||||
composerRelation?: IEventRelation;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
thread?: Thread;
|
||||
editState?: EditorStateTransfer;
|
||||
replyToEvent?: MatrixEvent;
|
||||
initialEventId?: string;
|
||||
isInitialEventHighlighted?: boolean;
|
||||
layout: Layout;
|
||||
atEndOfLiveTimeline: boolean;
|
||||
narrow: boolean;
|
||||
|
||||
// settings:
|
||||
showReadReceipts?: boolean;
|
||||
}
|
||||
|
||||
export default class TimelineCard extends React.Component<IProps, IState> {
|
||||
public static contextType = RoomContext;
|
||||
public declare context: React.ContextType<typeof RoomContext>;
|
||||
|
||||
private dispatcherRef?: string;
|
||||
private layoutWatcherRef?: string;
|
||||
private timelinePanel = React.createRef<TimelinePanel>();
|
||||
private card = React.createRef<HTMLDivElement>();
|
||||
private readReceiptsSettingWatcher: string | undefined;
|
||||
|
||||
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
showReadReceipts: SettingsStore.getValue("showReadReceipts", props.room.roomId),
|
||||
layout: SettingsStore.getValue("layout"),
|
||||
atEndOfLiveTimeline: true,
|
||||
narrow: false,
|
||||
};
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
SdkContextClass.instance.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdate);
|
||||
this.dispatcherRef = dis.register(this.onAction);
|
||||
this.readReceiptsSettingWatcher = SettingsStore.watchSetting("showReadReceipts", null, (...[, , , value]) =>
|
||||
this.setState({ showReadReceipts: value as boolean }),
|
||||
);
|
||||
this.layoutWatcherRef = SettingsStore.watchSetting("layout", null, (...[, , , value]) =>
|
||||
this.setState({ layout: value as Layout }),
|
||||
);
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
SdkContextClass.instance.roomViewStore.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate);
|
||||
|
||||
if (this.readReceiptsSettingWatcher) {
|
||||
SettingsStore.unwatchSetting(this.readReceiptsSettingWatcher);
|
||||
}
|
||||
if (this.layoutWatcherRef) {
|
||||
SettingsStore.unwatchSetting(this.layoutWatcherRef);
|
||||
}
|
||||
|
||||
if (this.dispatcherRef) dis.unregister(this.dispatcherRef);
|
||||
}
|
||||
|
||||
private onRoomViewStoreUpdate = async (_initial?: boolean): Promise<void> => {
|
||||
const newState: Pick<IState, any> = {
|
||||
initialEventId: SdkContextClass.instance.roomViewStore.getInitialEventId(),
|
||||
isInitialEventHighlighted: SdkContextClass.instance.roomViewStore.isInitialEventHighlighted(),
|
||||
replyToEvent: SdkContextClass.instance.roomViewStore.getQuotingEvent(),
|
||||
};
|
||||
|
||||
this.setState(newState);
|
||||
};
|
||||
|
||||
private onAction = (payload: ActionPayload): void => {
|
||||
switch (payload.action) {
|
||||
case Action.EditEvent:
|
||||
this.setState(
|
||||
{
|
||||
editState: payload.event ? new EditorStateTransfer(payload.event) : undefined,
|
||||
},
|
||||
() => {
|
||||
if (payload.event) {
|
||||
this.timelinePanel.current?.scrollToEventIfNeeded(payload.event.getId());
|
||||
}
|
||||
},
|
||||
);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
private onScroll = (): void => {
|
||||
const timelinePanel = this.timelinePanel.current;
|
||||
if (!timelinePanel) return;
|
||||
if (timelinePanel.isAtEndOfLiveTimeline()) {
|
||||
this.setState({
|
||||
atEndOfLiveTimeline: true,
|
||||
});
|
||||
} else {
|
||||
this.setState({
|
||||
atEndOfLiveTimeline: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (this.state.initialEventId && this.state.isInitialEventHighlighted) {
|
||||
dis.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: this.props.room.roomId,
|
||||
event_id: this.state.initialEventId,
|
||||
highlighted: false,
|
||||
replyingToEvent: this.state.replyToEvent,
|
||||
metricsTrigger: undefined, // room doesn't change
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
private onMeasurement = (narrow: boolean): void => {
|
||||
this.setState({ narrow });
|
||||
};
|
||||
|
||||
private jumpToLiveTimeline = (): void => {
|
||||
if (this.state.initialEventId && this.state.isInitialEventHighlighted) {
|
||||
// If we were viewing a highlighted event, firing view_room without
|
||||
// an event will take care of both clearing the URL fragment and
|
||||
// jumping to the bottom
|
||||
dis.dispatch({
|
||||
action: Action.ViewRoom,
|
||||
room_id: this.props.room.roomId,
|
||||
});
|
||||
} else {
|
||||
// Otherwise we have to jump manually
|
||||
this.timelinePanel.current?.jumpToLiveTimeline();
|
||||
dis.fire(Action.FocusSendMessageComposer);
|
||||
}
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const highlightedEventId = this.state.isInitialEventHighlighted ? this.state.initialEventId : undefined;
|
||||
|
||||
let jumpToBottom;
|
||||
if (!this.state.atEndOfLiveTimeline) {
|
||||
jumpToBottom = (
|
||||
<JumpToBottomButton
|
||||
highlight={this.props.room.getUnreadNotificationCount(NotificationCountType.Highlight) > 0}
|
||||
onScrollToBottomClick={this.jumpToLiveTimeline}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isUploading = ContentMessages.sharedInstance().getCurrentUploads(this.props.composerRelation).length > 0;
|
||||
|
||||
const myMembership = this.props.room.getMyMembership();
|
||||
const showComposer = myMembership === KnownMembership.Join;
|
||||
|
||||
return (
|
||||
<RoomContext.Provider
|
||||
value={{
|
||||
...this.context,
|
||||
timelineRenderingType: this.props.timelineRenderingType ?? this.context.timelineRenderingType,
|
||||
liveTimeline: this.props.timelineSet?.getLiveTimeline(),
|
||||
narrow: this.state.narrow,
|
||||
}}
|
||||
>
|
||||
<BaseCard
|
||||
className={this.props.classNames}
|
||||
onClose={this.props.onClose}
|
||||
withoutScrollContainer={true}
|
||||
header={_t("right_panel|video_room_chat|title")}
|
||||
ref={this.card}
|
||||
>
|
||||
{this.card.current && <Measured sensor={this.card.current} onMeasurement={this.onMeasurement} />}
|
||||
<div className="mx_TimelineCard_timeline">
|
||||
{jumpToBottom}
|
||||
<TimelinePanel
|
||||
ref={this.timelinePanel}
|
||||
showReadReceipts={this.state.showReadReceipts}
|
||||
manageReadReceipts={true}
|
||||
manageReadMarkers={false} // No RM support in the TimelineCard
|
||||
sendReadReceiptOnLoad={true}
|
||||
timelineSet={this.props.timelineSet}
|
||||
showUrlPreview={this.context.showUrlPreview}
|
||||
// The right panel timeline (and therefore threads) don't support IRC layout at this time
|
||||
layout={this.state.layout === Layout.Bubble ? Layout.Bubble : Layout.Group}
|
||||
hideThreadedMessages={false}
|
||||
hidden={false}
|
||||
showReactions={true}
|
||||
className="mx_RoomView_messagePanel"
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
membersLoaded={true}
|
||||
editState={this.state.editState}
|
||||
eventId={this.state.initialEventId}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
highlightedEventId={highlightedEventId}
|
||||
onScroll={this.onScroll}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isUploading && <UploadBar room={this.props.room} relation={this.props.composerRelation} />}
|
||||
|
||||
{showComposer && (
|
||||
<MessageComposer
|
||||
room={this.props.room}
|
||||
relation={this.props.composerRelation}
|
||||
resizeNotifier={this.props.resizeNotifier}
|
||||
replyToEvent={this.state.replyToEvent}
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
e2eStatus={this.props.e2eStatus}
|
||||
compact={true}
|
||||
/>
|
||||
)}
|
||||
</BaseCard>
|
||||
</RoomContext.Provider>
|
||||
);
|
||||
}
|
||||
}
|
1844
src/components/views/right_panel/UserInfo.tsx
Normal file
1844
src/components/views/right_panel/UserInfo.tsx
Normal file
File diff suppressed because it is too large
Load diff
469
src/components/views/right_panel/VerificationPanel.tsx
Normal file
469
src/components/views/right_panel/VerificationPanel.tsx
Normal file
|
@ -0,0 +1,469 @@
|
|||
/*
|
||||
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 {
|
||||
ShowQrCodeCallbacks,
|
||||
ShowSasCallbacks,
|
||||
VerificationPhase as Phase,
|
||||
VerificationRequest,
|
||||
VerificationRequestEvent,
|
||||
VerifierEvent,
|
||||
} from "matrix-js-sdk/src/crypto-api";
|
||||
import { Device, RoomMember, User } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { VerificationMethod } from "matrix-js-sdk/src/types";
|
||||
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import VerificationQRCode from "../elements/crypto/VerificationQRCode";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SdkConfig from "../../../SdkConfig";
|
||||
import E2EIcon, { E2EState } from "../rooms/E2EIcon";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import VerificationShowSas from "../verification/VerificationShowSas";
|
||||
import { getDeviceCryptoInfo } from "../../../utils/crypto/deviceInfo";
|
||||
|
||||
interface IProps {
|
||||
layout: string;
|
||||
request: VerificationRequest;
|
||||
member: RoomMember | User;
|
||||
phase?: Phase;
|
||||
onClose: () => void;
|
||||
isRoomEncrypted: boolean;
|
||||
inDialog: boolean;
|
||||
}
|
||||
|
||||
interface IState {
|
||||
/**
|
||||
* The data for the QR code to display.
|
||||
*
|
||||
* We attempt to calculate this once the verification request transitions into the "Ready" phase. If the other
|
||||
* side cannot scan QR codes, it will remain `undefined`.
|
||||
*/
|
||||
qrCodeBytes: Buffer | undefined;
|
||||
|
||||
sasEvent: ShowSasCallbacks | null;
|
||||
emojiButtonClicked?: boolean;
|
||||
reciprocateButtonClicked?: boolean;
|
||||
reciprocateQREvent: ShowQrCodeCallbacks | null;
|
||||
|
||||
/**
|
||||
* Details of the other device involved in the transaction.
|
||||
*
|
||||
* `undefined` if there is not (yet) another device in the transaction, or if we do not know about it.
|
||||
*/
|
||||
otherDeviceDetails?: Device;
|
||||
}
|
||||
|
||||
export default class VerificationPanel extends React.PureComponent<IProps, IState> {
|
||||
private hasVerifier: boolean;
|
||||
|
||||
/** have we yet tried to check the other device's info */
|
||||
private haveCheckedDevice = false;
|
||||
|
||||
/** have we yet tried to get the QR code */
|
||||
private haveFetchedQRCode = false;
|
||||
|
||||
public constructor(props: IProps) {
|
||||
super(props);
|
||||
this.state = { qrCodeBytes: undefined, sasEvent: null, reciprocateQREvent: null };
|
||||
this.hasVerifier = false;
|
||||
}
|
||||
|
||||
private renderQRPhase(): JSX.Element {
|
||||
const { member, request } = this.props;
|
||||
const showSAS: boolean = request.otherPartySupportsMethod(VerificationMethod.Sas);
|
||||
const showQR: boolean = request.otherPartySupportsMethod(VerificationMethod.ScanQrCode);
|
||||
const brand = SdkConfig.get().brand;
|
||||
|
||||
const noCommonMethodError: JSX.Element | null =
|
||||
!showSAS && !showQR ? <p>{_t("encryption|verification|no_support_qr_emoji", { brand })}</p> : null;
|
||||
|
||||
if (this.props.layout === "dialog") {
|
||||
// HACK: This is a terrible idea.
|
||||
let qrBlockDialog: JSX.Element | undefined;
|
||||
let sasBlockDialog: JSX.Element | undefined;
|
||||
if (showQR) {
|
||||
qrBlockDialog = (
|
||||
<div className="mx_VerificationPanel_QRPhase_startOption">
|
||||
<p>{_t("encryption|verification|qr_prompt")}</p>
|
||||
<VerificationQRCode qrCodeBytes={this.state.qrCodeBytes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (showSAS) {
|
||||
sasBlockDialog = (
|
||||
<div className="mx_VerificationPanel_QRPhase_startOption">
|
||||
<p>{_t("encryption|verification|sas_prompt")}</p>
|
||||
<span className="mx_VerificationPanel_QRPhase_helpText">
|
||||
{_t("encryption|verification|sas_description")}
|
||||
</span>
|
||||
<AccessibleButton
|
||||
disabled={this.state.emojiButtonClicked}
|
||||
onClick={this.startSAS}
|
||||
kind="primary"
|
||||
>
|
||||
{_t("action|start")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const or =
|
||||
qrBlockDialog && sasBlockDialog ? (
|
||||
<div className="mx_VerificationPanel_QRPhase_betweenText">
|
||||
{_t("encryption|verification|qr_or_sas", {
|
||||
emojiCompare: "",
|
||||
qrCode: "",
|
||||
})}
|
||||
</div>
|
||||
) : null;
|
||||
return (
|
||||
<div>
|
||||
{_t("encryption|verification|qr_or_sas_header")}
|
||||
<div className="mx_VerificationPanel_QRPhase_startOptions">
|
||||
{qrBlockDialog}
|
||||
{or}
|
||||
{sasBlockDialog}
|
||||
{noCommonMethodError}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let qrBlock: JSX.Element | undefined;
|
||||
if (showQR) {
|
||||
qrBlock = (
|
||||
<div className="mx_UserInfo_container">
|
||||
<h3>{_t("encryption|verification|scan_qr")}</h3>
|
||||
<p>
|
||||
{_t("encryption|verification|scan_qr_explainer", {
|
||||
displayName: (member as User).displayName || (member as RoomMember).name || member.userId,
|
||||
})}
|
||||
</p>
|
||||
|
||||
<div className="mx_VerificationPanel_qrCode">
|
||||
<VerificationQRCode qrCodeBytes={this.state.qrCodeBytes} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let sasBlock: JSX.Element | undefined;
|
||||
if (showSAS) {
|
||||
const disabled = this.state.emojiButtonClicked;
|
||||
const sasLabel = showQR
|
||||
? _t("encryption|verification|verify_emoji_prompt_qr")
|
||||
: _t("encryption|verification|verify_emoji_prompt");
|
||||
|
||||
// Note: mx_VerificationPanel_verifyByEmojiButton is for the end-to-end tests
|
||||
sasBlock = (
|
||||
<div className="mx_UserInfo_container">
|
||||
<h3>{_t("encryption|verification|verify_emoji")}</h3>
|
||||
<p>{sasLabel}</p>
|
||||
<AccessibleButton
|
||||
disabled={disabled}
|
||||
kind="primary"
|
||||
className="mx_UserInfo_wideButton mx_VerificationPanel_verifyByEmojiButton"
|
||||
onClick={this.startSAS}
|
||||
>
|
||||
{_t("encryption|verification|verify_emoji")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const noCommonMethodBlock = noCommonMethodError ? (
|
||||
<div className="mx_UserInfo_container">{noCommonMethodError}</div>
|
||||
) : null;
|
||||
|
||||
// TODO: add way to open camera to scan a QR code
|
||||
return (
|
||||
<React.Fragment>
|
||||
{qrBlock}
|
||||
{sasBlock}
|
||||
{noCommonMethodBlock}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
private onReciprocateYesClick = (): void => {
|
||||
if (!this.state.reciprocateQREvent) return;
|
||||
this.setState({ reciprocateButtonClicked: true });
|
||||
this.state.reciprocateQREvent?.confirm();
|
||||
};
|
||||
|
||||
private onReciprocateNoClick = (): void => {
|
||||
if (!this.state.reciprocateQREvent) return;
|
||||
this.setState({ reciprocateButtonClicked: true });
|
||||
this.state.reciprocateQREvent?.cancel();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get details of the other device involved in the verification, if we haven't before, and store in the state.
|
||||
*/
|
||||
private async maybeGetOtherDevice(): Promise<void> {
|
||||
if (this.haveCheckedDevice) return;
|
||||
|
||||
const client = MatrixClientPeg.safeGet();
|
||||
const deviceId = this.props.request?.otherDeviceId;
|
||||
const userId = client.getUserId();
|
||||
if (!deviceId || !userId) {
|
||||
return;
|
||||
}
|
||||
this.haveCheckedDevice = true;
|
||||
this.setState({ otherDeviceDetails: await getDeviceCryptoInfo(client, userId, deviceId) });
|
||||
}
|
||||
|
||||
private renderQRReciprocatePhase(): JSX.Element {
|
||||
const { member, request } = this.props;
|
||||
const description = request.isSelfVerification
|
||||
? _t("encryption|verification|qr_reciprocate_same_shield_device")
|
||||
: _t("encryption|verification|qr_reciprocate_same_shield_user", {
|
||||
displayName: (member as User).displayName || (member as RoomMember).name || member.userId,
|
||||
});
|
||||
let body: JSX.Element;
|
||||
if (this.state.reciprocateQREvent) {
|
||||
// Element Web doesn't support scanning yet, so assume here we're the client being scanned.
|
||||
body = (
|
||||
<React.Fragment>
|
||||
<p>{description}</p>
|
||||
<E2EIcon isUser={true} status={E2EState.Verified} size={128} hideTooltip={true} />
|
||||
<div className="mx_VerificationPanel_reciprocateButtons">
|
||||
<AccessibleButton
|
||||
kind="danger"
|
||||
disabled={this.state.reciprocateButtonClicked}
|
||||
onClick={this.onReciprocateNoClick}
|
||||
>
|
||||
{_t("action|no")}
|
||||
</AccessibleButton>
|
||||
<AccessibleButton
|
||||
kind="primary"
|
||||
disabled={this.state.reciprocateButtonClicked}
|
||||
onClick={this.onReciprocateYesClick}
|
||||
>
|
||||
{_t("action|yes")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<p>
|
||||
<Spinner />
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mx_UserInfo_container mx_VerificationPanel_reciprocate_section">
|
||||
<h3>{_t("encryption|verification|scan_qr")}</h3>
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderVerifiedPhase(): JSX.Element {
|
||||
const { member, request } = this.props;
|
||||
|
||||
let text: string | undefined;
|
||||
if (!request.isSelfVerification) {
|
||||
if (this.props.isRoomEncrypted) {
|
||||
text = _t("encryption|verification|prompt_encrypted");
|
||||
} else {
|
||||
text = _t("encryption|verification|prompt_unencrypted");
|
||||
}
|
||||
}
|
||||
|
||||
let description: string;
|
||||
if (request.isSelfVerification) {
|
||||
const device = this.state.otherDeviceDetails;
|
||||
if (!device) {
|
||||
// This can happen if the device is logged out while we're still showing verification
|
||||
// UI for it.
|
||||
logger.warn("Verified device we don't know about: " + this.props.request.otherDeviceId);
|
||||
description = _t("encryption|verification|successful_own_device");
|
||||
} else {
|
||||
description = _t("encryption|verification|successful_device", {
|
||||
deviceName: device.displayName,
|
||||
deviceId: device.deviceId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
description = _t("encryption|verification|successful_user", {
|
||||
displayName: (member as User).displayName || (member as RoomMember).name || member.userId,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_UserInfo_container mx_VerificationPanel_verified_section">
|
||||
<p>{description}</p>
|
||||
<E2EIcon isUser={true} status={E2EState.Verified} size={128} hideTooltip={true} />
|
||||
{text ? <p>{text}</p> : null}
|
||||
<AccessibleButton kind="primary" className="mx_UserInfo_wideButton" onClick={this.props.onClose}>
|
||||
{_t("action|got_it")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
private renderCancelledPhase(): JSX.Element {
|
||||
const { member, request } = this.props;
|
||||
|
||||
let startAgainInstruction: string;
|
||||
if (request.isSelfVerification) {
|
||||
startAgainInstruction = _t("encryption|verification|prompt_self");
|
||||
} else {
|
||||
startAgainInstruction = _t("encryption|verification|prompt_user");
|
||||
}
|
||||
|
||||
let text: string;
|
||||
if (request.cancellationCode === "m.timeout") {
|
||||
text = _t("encryption|verification|timed_out") + ` ${startAgainInstruction}`;
|
||||
} else if (request.cancellingUserId === request.otherUserId) {
|
||||
if (request.isSelfVerification) {
|
||||
text = _t("encryption|verification|cancelled_self");
|
||||
} else {
|
||||
text = _t("encryption|verification|cancelled_user", {
|
||||
displayName: (member as User).displayName || (member as RoomMember).name || member.userId,
|
||||
});
|
||||
}
|
||||
text = `${text} ${startAgainInstruction}`;
|
||||
} else {
|
||||
text = _t("encryption|verification|cancelled") + ` ${startAgainInstruction}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_UserInfo_container">
|
||||
<h3>{_t("common|verification_cancelled")}</h3>
|
||||
<p>{text}</p>
|
||||
|
||||
<AccessibleButton kind="primary" className="mx_UserInfo_wideButton" onClick={this.props.onClose}>
|
||||
{_t("action|got_it")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const { member, phase, request } = this.props;
|
||||
|
||||
const displayName = (member as User).displayName || (member as RoomMember).name || member.userId;
|
||||
|
||||
switch (phase) {
|
||||
case Phase.Ready:
|
||||
return this.renderQRPhase();
|
||||
case Phase.Started:
|
||||
switch (request.chosenMethod) {
|
||||
case VerificationMethod.Reciprocate:
|
||||
return this.renderQRReciprocatePhase();
|
||||
case VerificationMethod.Sas: {
|
||||
const emojis = this.state.sasEvent ? (
|
||||
<VerificationShowSas
|
||||
displayName={displayName}
|
||||
otherDeviceDetails={this.state.otherDeviceDetails}
|
||||
sas={this.state.sasEvent.sas}
|
||||
onCancel={this.onSasMismatchesClick}
|
||||
onDone={this.onSasMatchesClick}
|
||||
inDialog={this.props.inDialog}
|
||||
isSelf={request.isSelfVerification}
|
||||
/>
|
||||
) : (
|
||||
<Spinner />
|
||||
);
|
||||
return <div className="mx_UserInfo_container">{emojis}</div>;
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
case Phase.Done:
|
||||
return this.renderVerifiedPhase();
|
||||
case Phase.Cancelled:
|
||||
return this.renderCancelledPhase();
|
||||
}
|
||||
logger.error("VerificationPanel unhandled phase:", phase);
|
||||
return null;
|
||||
}
|
||||
|
||||
private startSAS = async (): Promise<void> => {
|
||||
this.setState({ emojiButtonClicked: true });
|
||||
await this.props.request.startVerification(VerificationMethod.Sas);
|
||||
};
|
||||
|
||||
private onSasMatchesClick = (): void => {
|
||||
this.state.sasEvent?.confirm();
|
||||
};
|
||||
|
||||
private onSasMismatchesClick = (): void => {
|
||||
this.state.sasEvent?.mismatch();
|
||||
};
|
||||
|
||||
private updateVerifierState = (): void => {
|
||||
// this method is only called once we know there is a verifier.
|
||||
const verifier = this.props.request.verifier!;
|
||||
const sasEvent = verifier.getShowSasCallbacks();
|
||||
const reciprocateQREvent = verifier.getReciprocateQrCodeCallbacks();
|
||||
verifier.off(VerifierEvent.ShowSas, this.updateVerifierState);
|
||||
verifier.off(VerifierEvent.ShowReciprocateQr, this.updateVerifierState);
|
||||
this.setState({ sasEvent, reciprocateQREvent });
|
||||
};
|
||||
|
||||
private onRequestChange = async (): Promise<void> => {
|
||||
const { request } = this.props;
|
||||
|
||||
// if we have a device ID and did not have one before, fetch the device's details
|
||||
this.maybeGetOtherDevice();
|
||||
|
||||
// if we have had a reply from the other side (ie, the phase is "ready") and we have not
|
||||
// yet done so, fetch the QR code
|
||||
if (request.phase === Phase.Ready && !this.haveFetchedQRCode) {
|
||||
this.haveFetchedQRCode = true;
|
||||
request.generateQRCode().then(
|
||||
(buf) => {
|
||||
this.setState({ qrCodeBytes: buf });
|
||||
},
|
||||
(error) => {
|
||||
console.error("Error generating QR code:", error);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const hadVerifier = this.hasVerifier;
|
||||
this.hasVerifier = !!request.verifier;
|
||||
if (!hadVerifier && this.hasVerifier) {
|
||||
request.verifier?.on(VerifierEvent.ShowSas, this.updateVerifierState);
|
||||
request.verifier?.on(VerifierEvent.ShowReciprocateQr, this.updateVerifierState);
|
||||
try {
|
||||
// on the requester side, this is also awaited in startSAS,
|
||||
// but that's ok as verify should return the same promise.
|
||||
await request.verifier?.verify();
|
||||
} catch (err) {
|
||||
logger.error("error verify", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public componentDidMount(): void {
|
||||
const { request } = this.props;
|
||||
request.on(VerificationRequestEvent.Change, this.onRequestChange);
|
||||
if (request.verifier) {
|
||||
const sasEvent = request.verifier.getShowSasCallbacks();
|
||||
const reciprocateQREvent = request.verifier.getReciprocateQrCodeCallbacks();
|
||||
this.setState({ sasEvent, reciprocateQREvent });
|
||||
}
|
||||
this.onRequestChange();
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
const { request } = this.props;
|
||||
if (request.verifier) {
|
||||
request.verifier.off(VerifierEvent.ShowSas, this.updateVerifierState);
|
||||
request.verifier.off(VerifierEvent.ShowReciprocateQr, this.updateVerifierState);
|
||||
}
|
||||
request.off(VerificationRequestEvent.Change, this.onRequestChange);
|
||||
}
|
||||
}
|
97
src/components/views/right_panel/WidgetCard.tsx
Normal file
97
src/components/views/right_panel/WidgetCard.tsx
Normal file
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
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, { useContext, useEffect } from "react";
|
||||
import { Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import BaseCard from "./BaseCard";
|
||||
import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils";
|
||||
import AppTile from "../elements/AppTile";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { ChevronFace, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu";
|
||||
import { WidgetContextMenu } from "../context_menus/WidgetContextMenu";
|
||||
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
import UIStore from "../../../stores/UIStore";
|
||||
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
||||
import Heading from "../typography/Heading";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
widgetId: string;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
const WidgetCard: React.FC<IProps> = ({ room, widgetId, onClose }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
|
||||
const apps = useWidgets(room);
|
||||
const app = apps.find((a) => a.id === widgetId);
|
||||
const isRight = app && WidgetLayoutStore.instance.isInContainer(room, app, Container.Right);
|
||||
|
||||
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu();
|
||||
|
||||
useEffect(() => {
|
||||
if (!app || !isRight) {
|
||||
// stop showing this card
|
||||
RightPanelStore.instance.popCard();
|
||||
}
|
||||
}, [app, isRight]);
|
||||
|
||||
// Don't render anything as we are about to transition
|
||||
if (!app || !isRight) return null;
|
||||
|
||||
let contextMenu: JSX.Element | undefined;
|
||||
if (menuDisplayed) {
|
||||
const rect = handle.current?.getBoundingClientRect();
|
||||
const rightMargin = rect ? rect.right : 0;
|
||||
const bottomMargin = rect ? rect.bottom : 0;
|
||||
contextMenu = (
|
||||
<WidgetContextMenu
|
||||
chevronFace={ChevronFace.None}
|
||||
right={UIStore.instance.windowWidth - rightMargin - 12}
|
||||
top={bottomMargin + 12}
|
||||
onFinished={closeMenu}
|
||||
app={app}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const header = (
|
||||
<div className="mx_BaseCard_header_title">
|
||||
<Heading size="4" className="mx_BaseCard_header_title_heading">
|
||||
{WidgetUtils.getWidgetName(app)}
|
||||
</Heading>
|
||||
<ContextMenuButton
|
||||
className="mx_BaseCard_header_title_button--option"
|
||||
ref={handle}
|
||||
onClick={openMenu}
|
||||
isExpanded={menuDisplayed}
|
||||
label={_t("common|options")}
|
||||
/>
|
||||
{contextMenu}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<BaseCard header={header} className="mx_WidgetCard" onClose={onClose} withoutScrollContainer>
|
||||
<AppTile
|
||||
app={app}
|
||||
fullWidth
|
||||
showMenubar={false}
|
||||
room={room}
|
||||
userId={cli.getSafeUserId()}
|
||||
creatorUserId={app.creatorUserId}
|
||||
widgetPageTitle={WidgetUtils.getWidgetDataTitle(app)}
|
||||
waitForIframeLoad={app.waitForIframeLoad}
|
||||
/>
|
||||
</BaseCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default WidgetCard;
|
11
src/components/views/right_panel/context.ts
Normal file
11
src/components/views/right_panel/context.ts
Normal file
|
@ -0,0 +1,11 @@
|
|||
/*
|
||||
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";
|
||||
|
||||
export const CardContext = React.createContext({ isCard: false });
|
9
src/components/views/right_panel/types.ts
Normal file
9
src/components/views/right_panel/types.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
/*
|
||||
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 const ReadPinsEventId = "im.vector.room.read_pins";
|
Loading…
Add table
Add a link
Reference in a new issue