Merge matrix-react-sdk into element-web

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

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

View file

@ -0,0 +1,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;

View 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;

View 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;

View 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;

View 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;

View 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>
)}
</>
);
}

View 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;

View 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>
);
}
}

File diff suppressed because it is too large Load diff

View 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);
}
}

View 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;

View 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 });

View 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";