{previewBar} diff --git a/src/components/structures/ScrollPanel.tsx b/src/components/structures/ScrollPanel.tsx index 413d081746..a92b24fc55 100644 --- a/src/components/structures/ScrollPanel.tsx +++ b/src/components/structures/ScrollPanel.tsx @@ -6,14 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { createRef, CSSProperties, ReactNode } from "react"; +import React, { createRef, type CSSProperties, type ReactNode } from "react"; import { logger } from "matrix-js-sdk/src/logger"; import SettingsStore from "../../settings/SettingsStore"; import Timer from "../../utils/Timer"; import AutoHideScrollbar from "./AutoHideScrollbar"; import { getKeyBindingsManager } from "../../KeyBindingsManager"; -import ResizeNotifier from "../../utils/ResizeNotifier"; +import type ResizeNotifier from "../../utils/ResizeNotifier"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; // The amount of extra scroll distance to allow prior to unfilling. diff --git a/src/components/structures/SearchBox.tsx b/src/components/structures/SearchBox.tsx index a5bbb7865e..ec85314349 100644 --- a/src/components/structures/SearchBox.tsx +++ b/src/components/structures/SearchBox.tsx @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { createRef, HTMLProps } from "react"; +import React, { createRef, type HTMLProps } from "react"; import { throttle } from "lodash"; import classNames from "classnames"; diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index 362fe82dce..0d41e56c92 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -1,5 +1,5 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024,2025 New Vector Ltd. Copyright 2021-2023 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial @@ -7,42 +7,44 @@ Please see LICENSE files in the repository root for full details. */ import React, { - Dispatch, - KeyboardEvent, - KeyboardEventHandler, - ReactElement, - ReactNode, - SetStateAction, + type JSX, + type Dispatch, + type KeyboardEvent, + type KeyboardEventHandler, + type ReactElement, + type ReactNode, + type SetStateAction, useCallback, useContext, useEffect, + useId, useMemo, useRef, useState, } from "react"; import { - Room, + type Room, RoomEvent, ClientEvent, - MatrixClient, + type MatrixClient, MatrixError, EventType, RoomType, GuestAccess, HistoryVisibility, - HierarchyRelation, - HierarchyRoom, + type HierarchyRelation, + type HierarchyRoom, JoinRule, } from "matrix-js-sdk/src/matrix"; import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy"; import classNames from "classnames"; import { sortBy, uniqBy } from "lodash"; import { logger } from "matrix-js-sdk/src/logger"; -import { KnownMembership, SpaceChildEventContent } from "matrix-js-sdk/src/types"; +import { KnownMembership, type SpaceChildEventContent } from "matrix-js-sdk/src/types"; import defaultDispatcher from "../../dispatcher/dispatcher"; import { _t } from "../../languageHandler"; -import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; +import AccessibleButton, { type ButtonEvent } from "../views/elements/AccessibleButton"; import Spinner from "../views/elements/Spinner"; import SearchBox from "./SearchBox"; import RoomAvatar from "../views/avatars/RoomAvatar"; @@ -56,13 +58,13 @@ import { getChildOrder } from "../../stores/spaces/SpaceStore"; import { Linkify, topicToHtml } from "../../HtmlUtils"; import { useDispatcher } from "../../hooks/useDispatcher"; import { Action } from "../../dispatcher/actions"; -import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex"; +import { type IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import { useTypedEventEmitterState } from "../../hooks/useEventEmitter"; -import { IOOBData } from "../../stores/ThreepidInviteStore"; +import { type IOOBData } from "../../stores/ThreepidInviteStore"; import { awaitRoomDownSync } from "../../utils/RoomUpgrade"; -import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; -import { JoinRoomReadyPayload } from "../../dispatcher/payloads/JoinRoomReadyPayload"; +import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; +import { type JoinRoomReadyPayload } from "../../dispatcher/payloads/JoinRoomReadyPayload"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../KeyBindingsManager"; import { getTopic } from "../../hooks/room/useTopic"; @@ -116,6 +118,7 @@ const Tile: React.FC = ({ const [showChildren, toggleShowChildren] = useStateToggle(true); const [onFocus, isActive, ref, nodeRef] = useRovingTabIndex(); const [busy, setBusy] = useState(false); + const checkboxLabelId = useId(); const onPreviewClick = (ev: ButtonEvent): void => { ev.preventDefault(); @@ -172,7 +175,14 @@ const Tile: React.FC = ({ let checkbox: ReactElement | undefined; if (onToggleClick) { if (hasPermissions) { - checkbox = ; + checkbox = ( + + ); } else { checkbox = ( = ({ ev.stopPropagation(); }} > - + ); } @@ -248,7 +263,7 @@ const Tile: React.FC = ({
{avatar}
- {name} + {name} {joinedSection} {suggestedSection}
@@ -330,11 +345,14 @@ const Tile: React.FC = ({ }; } + const shouldToggle = hasPermissions && onToggleClick; + return (
  • = ({ mx_SpaceHierarchy_subspace: room.room_type === RoomType.Space, mx_SpaceHierarchy_joining: busy, })} - onClick={hasPermissions && onToggleClick ? onToggleClick : onPreviewClick} + onClick={shouldToggle ? onToggleClick : onPreviewClick} onKeyDown={onKeyDown} ref={ref} onFocus={onFocus} @@ -619,7 +637,7 @@ const useIntersectionObserver = (callback: () => void): ((element: HTMLDivElemen } }; - const observerRef = useRef(); + const observerRef = useRef(undefined); return (element: HTMLDivElement) => { if (observerRef.current) { observerRef.current.disconnect(); diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index 1424df2c98..59ec657b02 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -6,18 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { EventType, RoomType, JoinRule, Preset, Room, RoomEvent } from "matrix-js-sdk/src/matrix"; +import { EventType, RoomType, JoinRule, Preset, type Room, RoomEvent } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { logger } from "matrix-js-sdk/src/logger"; -import React, { useCallback, useContext, useRef, useState } from "react"; +import React, { type JSX, useCallback, useContext, useRef, useState } from "react"; import MatrixClientContext from "../../contexts/MatrixClientContext"; -import createRoom, { IOpts } from "../../createRoom"; +import createRoom, { type IOpts } from "../../createRoom"; import { shouldShowComponent } from "../../customisations/helpers/UIComponents"; import { Action } from "../../dispatcher/actions"; import defaultDispatcher from "../../dispatcher/dispatcher"; -import { ActionPayload } from "../../dispatcher/payloads"; -import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; +import { type ActionPayload } from "../../dispatcher/payloads"; +import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import * as Email from "../../email"; import { useEventEmitterState } from "../../hooks/useEventEmitter"; import { useMyRoomMembership } from "../../hooks/useRoomMembers"; @@ -30,7 +30,7 @@ import { UIComponent } from "../../settings/UIFeature"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import RightPanelStore from "../../stores/right-panel/RightPanelStore"; import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases"; -import ResizeNotifier from "../../utils/ResizeNotifier"; +import type ResizeNotifier from "../../utils/ResizeNotifier"; import { shouldShowSpaceInvite, shouldShowSpaceSettings, @@ -51,7 +51,7 @@ import { defaultDmsRenderer, defaultRoomsRenderer, } from "../views/dialogs/AddExistingToSpaceDialog"; -import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; +import AccessibleButton, { type ButtonEvent } from "../views/elements/AccessibleButton"; import ErrorBoundary from "../views/elements/ErrorBoundary"; import Field from "../views/elements/Field"; import RoomFacePile from "../views/elements/RoomFacePile"; @@ -65,7 +65,7 @@ import { ChevronFace, ContextMenuButton, useContextMenu } from "./ContextMenu"; import MainSplit from "./MainSplit"; import RightPanel from "./RightPanel"; import SpaceHierarchy, { showRoom } from "./SpaceHierarchy"; -import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; +import { type RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; interface IProps { space: Room; @@ -117,7 +117,7 @@ const SpaceLandingAddButton: React.FC<{ space: Room }> = ({ space }) => { <> => { e.preventDefault(); e.stopPropagation(); @@ -132,7 +132,7 @@ const SpaceLandingAddButton: React.FC<{ space: Room }> = ({ space }) => { {videoRoomsEnabled && ( => { e.preventDefault(); e.stopPropagation(); @@ -157,7 +157,7 @@ const SpaceLandingAddButton: React.FC<{ space: Room }> = ({ space }) => { )} { e.preventDefault(); e.stopPropagation(); @@ -168,7 +168,7 @@ const SpaceLandingAddButton: React.FC<{ space: Room }> = ({ space }) => { {canCreateSpace && ( { e.preventDefault(); e.stopPropagation(); diff --git a/src/components/structures/SplashPage.tsx b/src/components/structures/SplashPage.tsx index 1ce0724dab..86d78fcecc 100644 --- a/src/components/structures/SplashPage.tsx +++ b/src/components/structures/SplashPage.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import classNames from "classnames"; -import React, { DetailedHTMLProps, HTMLAttributes, ReactNode } from "react"; +import React, { type JSX, type DetailedHTMLProps, type HTMLAttributes, type ReactNode } from "react"; interface Props extends DetailedHTMLProps, HTMLElement> { className?: string; diff --git a/src/components/structures/TabbedView.tsx b/src/components/structures/TabbedView.tsx index 71161fec52..b01f160551 100644 --- a/src/components/structures/TabbedView.tsx +++ b/src/components/structures/TabbedView.tsx @@ -8,13 +8,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import * as React from "react"; +import React, { type JSX } from "react"; import classNames from "classnames"; -import { _t, TranslationKey } from "../../languageHandler"; +import { _t, type TranslationKey } from "../../languageHandler"; import AutoHideScrollbar from "./AutoHideScrollbar"; -import { PosthogScreenTracker, ScreenName } from "../../PosthogTrackers"; -import { NonEmptyArray } from "../../@types/common"; +import { PosthogScreenTracker, type ScreenName } from "../../PosthogTrackers"; +import { type NonEmptyArray } from "../../@types/common"; import { RovingAccessibleButton, RovingTabIndexProvider } from "../../accessibility/RovingTabIndex"; import { useWindowWidth } from "../../hooks/useWindowWidth"; @@ -27,14 +27,14 @@ export class Tab { * @param {string} id The tab's ID. * @param {string} label The untranslated tab label. * @param {string|JSX.Element} icon An SVG element to use for the tab icon. Can also be a string for legacy icons, in which case it is the class for the tab icon. This should be a simple mask. - * @param {React.ReactNode} body The JSX for the tab container. + * @param {JSX.Element} body The JSX for the tab container. * @param {string} screenName The screen name to report to Posthog. */ public constructor( public readonly id: T, public readonly label: TranslationKey, public readonly icon: string | JSX.Element | null, - public readonly body: React.ReactNode, + public readonly body: JSX.Element, public readonly screenName?: ScreenName, ) {} } diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index f6742d8159..892dc82c75 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -6,16 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Optional } from "matrix-events-sdk"; +import { type Optional } from "matrix-events-sdk"; import React, { useContext, useEffect, useRef, useState } from "react"; -import { EventTimelineSet, Room, Thread } from "matrix-js-sdk/src/matrix"; +import { type EventTimelineSet, type Room, Thread } from "matrix-js-sdk/src/matrix"; import { IconButton, Tooltip } from "@vector-im/compound-web"; import { logger } from "matrix-js-sdk/src/logger"; import ThreadsIcon from "@vector-im/compound-design-tokens/assets/web/icons/threads"; import { Icon as MarkAllThreadsReadIcon } from "../../../res/img/element-icons/check-all.svg"; import BaseCard from "../views/right_panel/BaseCard"; -import ResizeNotifier from "../../utils/ResizeNotifier"; +import type ResizeNotifier from "../../utils/ResizeNotifier"; import MatrixClientContext, { useMatrixClientContext } from "../../contexts/MatrixClientContext"; import { _t } from "../../languageHandler"; import { ContextMenuButton } from "../../accessibility/context_menu/ContextMenuButton"; @@ -23,10 +23,10 @@ import ContextMenu, { ChevronFace, MenuItemRadio, useContextMenu } from "./Conte import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; import TimelinePanel from "./TimelinePanel"; import { Layout } from "../../settings/enums/Layout"; -import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; +import { type RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; import Measured from "../views/elements/Measured"; import PosthogTrackers from "../../PosthogTrackers"; -import { ButtonEvent } from "../views/elements/AccessibleButton"; +import { type ButtonEvent } from "../views/elements/AccessibleButton"; import Spinner from "../views/elements/Spinner"; import { clearRoomNotification } from "../../utils/notifications"; import EmptyState from "../views/right_panel/EmptyState"; @@ -129,10 +129,10 @@ export const ThreadPanelHeader: React.FC<{ ); return ( -
    +
    - - + +
    @@ -192,9 +192,7 @@ const ThreadPanel: React.FC = ({ roomId, onClose, permalinkCreator }) => narrow={narrow} > - } + header={_t("common|threads")} id="thread-panel" className="mx_ThreadPanel" ariaLabelledBy="thread-panel-tab" @@ -204,6 +202,7 @@ const ThreadPanel: React.FC = ({ roomId, onClose, permalinkCreator }) => ref={card} closeButtonRef={closeButonRef} > + {hasThreads && } {timelineSet ? ( { // Set by setEventId in ctor. private eventId!: string; - public constructor(props: IProps, context: React.ContextType) { - super(props, context); + public constructor(props: IProps) { + super(props); this.setEventId(this.props.mxEvent); const thread = this.props.room.getThread(this.eventId) ?? undefined; diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index 31ce65cfa7..8346a0ab31 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -6,35 +6,35 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { createRef, ReactNode } from "react"; +import React, { createRef, type ReactNode } from "react"; import { - Room, + type Room, RoomEvent, - RoomMember, + type RoomMember, RoomMemberEvent, - MatrixEvent, + type MatrixEvent, MatrixEventEvent, - EventTimelineSet, - IRoomTimelineData, + type EventTimelineSet, + type IRoomTimelineData, Direction, EventTimeline, EventType, - RelationType, + type RelationType, ClientEvent, - MatrixClient, - Relations, - MatrixError, - SyncState, + type MatrixClient, + type Relations, + type MatrixError, + type SyncState, TimelineWindow, Thread, ThreadEvent, ReceiptType, } from "matrix-js-sdk/src/matrix"; -import { debounce, findLastIndex } from "lodash"; +import { debounce } from "lodash"; import { logger } from "matrix-js-sdk/src/logger"; import SettingsStore from "../../settings/SettingsStore"; -import { Layout } from "../../settings/enums/Layout"; +import { type Layout } from "../../settings/enums/Layout"; import { _t } from "../../languageHandler"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import RoomContext, { TimelineRenderingType } from "../../contexts/RoomContext"; @@ -45,15 +45,16 @@ import { Action } from "../../dispatcher/actions"; import Timer from "../../utils/Timer"; import shouldHideEvent from "../../shouldHideEvent"; import MessagePanel from "./MessagePanel"; -import { IScrollState } from "./ScrollPanel"; -import { ActionPayload } from "../../dispatcher/payloads"; -import ResizeNotifier from "../../utils/ResizeNotifier"; -import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; +import { type IScrollState } from "./ScrollPanel"; +import { type ActionPayload } from "../../dispatcher/payloads"; +import type ResizeNotifier from "../../utils/ResizeNotifier"; +import { type RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; import Spinner from "../views/elements/Spinner"; -import EditorStateTransfer from "../../utils/EditorStateTransfer"; +import type EditorStateTransfer from "../../utils/EditorStateTransfer"; import ErrorDialog from "../views/dialogs/ErrorDialog"; -import LegacyCallEventGrouper, { buildLegacyCallEventGroupers } from "./LegacyCallEventGrouper"; -import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; +import type LegacyCallEventGrouper from "./LegacyCallEventGrouper"; +import { buildLegacyCallEventGroupers } from "./LegacyCallEventGrouper"; +import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { getKeyBindingsManager } from "../../KeyBindingsManager"; import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; import { haveRendererForEvent } from "../../events/EventTileFactory"; @@ -72,25 +73,12 @@ const debuglog = (...args: any[]): void => { } }; -const overlaysBefore = (overlayEvent: MatrixEvent, mainEvent: MatrixEvent): boolean => - overlayEvent.localTimestamp < mainEvent.localTimestamp; - -const overlaysAfter = (overlayEvent: MatrixEvent, mainEvent: MatrixEvent): boolean => - overlayEvent.localTimestamp >= mainEvent.localTimestamp; - interface IProps { // The js-sdk EventTimelineSet object for the timeline sequence we are // representing. This may or may not have a room, depending on what it's // a timeline representing. If it has a room, we maintain RRs etc for // that room. timelineSet: EventTimelineSet; - // overlay events from a second timelineset on the main timeline - // added to support virtual rooms - // events from the overlay timeline set will be added by localTimestamp - // into the main timeline - overlayTimelineSet?: EventTimelineSet; - // filter events from overlay timeline - overlayTimelineSetFilter?: (event: MatrixEvent) => boolean; showReadReceipts?: boolean; // Enable managing RRs and RMs. These require the timelineSet to have a room. manageReadReceipts?: boolean; @@ -250,7 +238,6 @@ class TimelinePanel extends React.Component { private readonly messagePanel = createRef(); private dispatcherRef?: string; private timelineWindow?: TimelineWindow; - private overlayTimelineWindow?: TimelineWindow; private unmounted = false; private readReceiptActivityTimer: Timer | null = null; private readMarkerActivityTimer: Timer | null = null; @@ -348,16 +335,12 @@ class TimelinePanel extends React.Component { const differentEventId = prevProps.eventId != this.props.eventId; const differentHighlightedEventId = prevProps.highlightedEventId != this.props.highlightedEventId; const differentAvoidJump = prevProps.eventScrollIntoView && !this.props.eventScrollIntoView; - const differentOverlayTimeline = prevProps.overlayTimelineSet !== this.props.overlayTimelineSet; if (differentEventId || differentHighlightedEventId || differentAvoidJump) { logger.log( `TimelinePanel switching to eventId ${this.props.eventId} (was ${prevProps.eventId}), ` + `scrollIntoView: ${this.props.eventScrollIntoView} (was ${prevProps.eventScrollIntoView})`, ); this.initTimeline(this.props); - } else if (differentOverlayTimeline) { - logger.log(`TimelinePanel updating overlay timeline.`); - this.initTimeline(this.props); } } @@ -508,24 +491,9 @@ class TimelinePanel extends React.Component { // this particular event should be the first or last to be unpaginated. const eventId = scrollToken; - // The event in question could belong to either the main timeline or - // overlay timeline; let's check both const mainEvents = this.timelineWindow!.getEvents(); - const overlayEvents = this.overlayTimelineWindow?.getEvents() ?? []; - let marker = mainEvents.findIndex((ev) => ev.getId() === eventId); - let overlayMarker: number; - if (marker === -1) { - // The event must be from the overlay timeline instead - overlayMarker = overlayEvents.findIndex((ev) => ev.getId() === eventId); - marker = backwards - ? findLastIndex(mainEvents, (ev) => overlaysAfter(overlayEvents[overlayMarker], ev)) - : mainEvents.findIndex((ev) => overlaysBefore(overlayEvents[overlayMarker], ev)); - } else { - overlayMarker = backwards - ? findLastIndex(overlayEvents, (ev) => overlaysBefore(ev, mainEvents[marker])) - : overlayEvents.findIndex((ev) => overlaysAfter(ev, mainEvents[marker])); - } + const marker = mainEvents.findIndex((ev) => ev.getId() === eventId); // The number of events to unpaginate from the main timeline let count: number; @@ -535,24 +503,11 @@ class TimelinePanel extends React.Component { count = backwards ? marker + 1 : mainEvents.length - marker; } - // The number of events to unpaginate from the overlay timeline - let overlayCount: number; - if (overlayMarker === -1) { - overlayCount = 0; - } else { - overlayCount = backwards ? overlayMarker + 1 : overlayEvents.length - overlayMarker; - } - if (count > 0) { debuglog("Unpaginating", count, "in direction", dir); this.timelineWindow!.unpaginate(count, backwards); } - if (overlayCount > 0) { - debuglog("Unpaginating", count, "from overlay timeline in direction", dir); - this.overlayTimelineWindow!.unpaginate(overlayCount, backwards); - } - const { events, liveEvents } = this.getEvents(); this.buildLegacyCallEventGroupers(events); this.setState({ @@ -609,10 +564,6 @@ class TimelinePanel extends React.Component { return false; } - if (this.overlayTimelineWindow) { - await this.extendOverlayWindowToCoverMainWindow(); - } - debuglog("paginate complete backwards:" + backwards + "; success:" + r); const { events, liveEvents } = this.getEvents(); @@ -704,10 +655,7 @@ class TimelinePanel extends React.Component { data: IRoomTimelineData, ): void => { // ignore events for other timeline sets - if ( - data.timeline.getTimelineSet() !== this.props.timelineSet && - data.timeline.getTimelineSet() !== this.props.overlayTimelineSet - ) { + if (data.timeline.getTimelineSet() !== this.props.timelineSet) { return; } @@ -747,69 +695,60 @@ class TimelinePanel extends React.Component { // timeline window. // // see https://github.com/vector-im/vector-web/issues/1035 - this.timelineWindow!.paginate(EventTimeline.FORWARDS, 1, false) - .then(() => { - if (this.overlayTimelineWindow) { - return this.overlayTimelineWindow.paginate(EventTimeline.FORWARDS, 1, false); + this.timelineWindow!.paginate(EventTimeline.FORWARDS, 1, false).then(() => { + if (this.unmounted) { + return; + } + + const { events, liveEvents } = this.getEvents(); + this.buildLegacyCallEventGroupers(events); + const lastLiveEvent = liveEvents[liveEvents.length - 1]; + + const updatedState: Partial = { + events, + liveEvents, + }; + + let callRMUpdated = false; + if (this.props.manageReadMarkers) { + // when a new event arrives when the user is not watching the + // window, but the window is in its auto-scroll mode, make sure the + // read marker is visible. + // + // We ignore events we have sent ourselves; we don't want to see the + // read-marker when a remote echo of an event we have just sent takes + // more than the timeout on userActiveRecently. + // + const myUserId = MatrixClientPeg.safeGet().credentials.userId; + callRMUpdated = false; + if (ev.getSender() !== myUserId && !UserActivity.sharedInstance().userActiveRecently()) { + updatedState.readMarkerVisible = true; + } else if (lastLiveEvent && this.getReadMarkerPosition() === 0) { + // we know we're stuckAtBottom, so we can advance the RM + // immediately, to save a later render cycle + + this.setReadMarker(lastLiveEvent.getId() ?? null, lastLiveEvent.getTs(), true); + updatedState.readMarkerVisible = false; + updatedState.readMarkerEventId = lastLiveEvent.getId(); + callRMUpdated = true; } - }) - .then(() => { - if (this.unmounted) { - return; + } + + this.setState(updatedState as IState, () => { + this.messagePanel.current?.updateTimelineMinHeight(); + if (callRMUpdated) { + this.props.onReadMarkerUpdated?.(); } - - const { events, liveEvents } = this.getEvents(); - this.buildLegacyCallEventGroupers(events); - const lastLiveEvent = liveEvents[liveEvents.length - 1]; - - const updatedState: Partial = { - events, - liveEvents, - }; - - let callRMUpdated = false; - if (this.props.manageReadMarkers) { - // when a new event arrives when the user is not watching the - // window, but the window is in its auto-scroll mode, make sure the - // read marker is visible. - // - // We ignore events we have sent ourselves; we don't want to see the - // read-marker when a remote echo of an event we have just sent takes - // more than the timeout on userActiveRecently. - // - const myUserId = MatrixClientPeg.safeGet().credentials.userId; - callRMUpdated = false; - if (ev.getSender() !== myUserId && !UserActivity.sharedInstance().userActiveRecently()) { - updatedState.readMarkerVisible = true; - } else if (lastLiveEvent && this.getReadMarkerPosition() === 0) { - // we know we're stuckAtBottom, so we can advance the RM - // immediately, to save a later render cycle - - this.setReadMarker(lastLiveEvent.getId() ?? null, lastLiveEvent.getTs(), true); - updatedState.readMarkerVisible = false; - updatedState.readMarkerEventId = lastLiveEvent.getId(); - callRMUpdated = true; - } - } - - this.setState(updatedState as IState, () => { - this.messagePanel.current?.updateTimelineMinHeight(); - if (callRMUpdated) { - this.props.onReadMarkerUpdated?.(); - } - }); }); + }); }; private hasTimelineSetFor(roomId: string | undefined): boolean { - return ( - (roomId !== undefined && roomId === this.props.timelineSet.room?.roomId) || - roomId === this.props.overlayTimelineSet?.room?.roomId - ); + return roomId !== undefined && roomId === this.props.timelineSet.room?.roomId; } private onRoomTimelineReset = (room: Room | undefined, timelineSet: EventTimelineSet): void => { - if (timelineSet !== this.props.timelineSet && timelineSet !== this.props.overlayTimelineSet) return; + if (timelineSet !== this.props.timelineSet) return; if (this.canResetTimeline()) { this.loadTimeline(); @@ -1474,48 +1413,6 @@ class TimelinePanel extends React.Component { }); } - private async extendOverlayWindowToCoverMainWindow(): Promise { - const mainWindow = this.timelineWindow!; - const overlayWindow = this.overlayTimelineWindow!; - const mainEvents = mainWindow.getEvents(); - - if (mainEvents.length > 0) { - let paginationRequests: Promise[]; - - // Keep paginating until the main window is covered - do { - paginationRequests = []; - const overlayEvents = overlayWindow.getEvents(); - - if ( - overlayWindow.canPaginate(EventTimeline.BACKWARDS) && - (overlayEvents.length === 0 || - overlaysAfter(overlayEvents[0], mainEvents[0]) || - !mainWindow.canPaginate(EventTimeline.BACKWARDS)) - ) { - // Paginating backwards could reveal more events to be overlaid in the main window - paginationRequests.push( - this.onPaginationRequest(overlayWindow, EventTimeline.BACKWARDS, PAGINATE_SIZE), - ); - } - - if ( - overlayWindow.canPaginate(EventTimeline.FORWARDS) && - (overlayEvents.length === 0 || - overlaysBefore(overlayEvents.at(-1)!, mainEvents.at(-1)!) || - !mainWindow.canPaginate(EventTimeline.FORWARDS)) - ) { - // Paginating forwards could reveal more events to be overlaid in the main window - paginationRequests.push( - this.onPaginationRequest(overlayWindow, EventTimeline.FORWARDS, PAGINATE_SIZE), - ); - } - - await Promise.all(paginationRequests); - } while (paginationRequests.length > 0); - } - } - /** * (re)-load the event timeline, and initialise the scroll state, centered * around the given event. @@ -1535,9 +1432,6 @@ class TimelinePanel extends React.Component { private loadTimeline(eventId?: string, pixelOffset?: number, offsetBase?: number, scrollIntoView = true): void { const cli = MatrixClientPeg.safeGet(); this.timelineWindow = new TimelineWindow(cli, this.props.timelineSet, { windowLimit: this.props.timelineCap }); - this.overlayTimelineWindow = this.props.overlayTimelineSet - ? new TimelineWindow(cli, this.props.overlayTimelineSet, { windowLimit: this.props.timelineCap }) - : undefined; const onLoaded = (): void => { if (this.unmounted) return; @@ -1553,14 +1447,8 @@ class TimelinePanel extends React.Component { this.setState( { - canBackPaginate: - (this.timelineWindow?.canPaginate(EventTimeline.BACKWARDS) || - this.overlayTimelineWindow?.canPaginate(EventTimeline.BACKWARDS)) ?? - false, - canForwardPaginate: - (this.timelineWindow?.canPaginate(EventTimeline.FORWARDS) || - this.overlayTimelineWindow?.canPaginate(EventTimeline.FORWARDS)) ?? - false, + canBackPaginate: this.timelineWindow?.canPaginate(EventTimeline.BACKWARDS) ?? false, + canForwardPaginate: this.timelineWindow?.canPaginate(EventTimeline.FORWARDS) ?? false, timelineLoading: false, }, () => { @@ -1617,11 +1505,13 @@ class TimelinePanel extends React.Component { description = _t("timeline|load_error|unable_to_find"); } - Modal.createDialog(ErrorDialog, { + const { finished } = Modal.createDialog(ErrorDialog, { title: _t("timeline|load_error|title"), description, - onFinished, }); + if (onFinished) { + finished.then(onFinished); + } }; // if we already have the event in question, TimelineWindow.load @@ -1635,7 +1525,7 @@ class TimelinePanel extends React.Component { // This is a hot-path optimization by skipping a promise tick // by repeating a no-op sync branch in // TimelineSet.getTimelineForEvent & MatrixClient.getEventTimeline - if (this.props.timelineSet.getTimelineForEvent(eventId) && !this.overlayTimelineWindow) { + if (this.props.timelineSet.getTimelineForEvent(eventId)) { // if we've got an eventId, and the timeline exists, we can skip // the promise tick. this.timelineWindow.load(eventId, INITIAL_SIZE); @@ -1644,14 +1534,7 @@ class TimelinePanel extends React.Component { return; } - const prom = this.timelineWindow.load(eventId, INITIAL_SIZE).then(async (): Promise => { - if (this.overlayTimelineWindow) { - // TODO: use timestampToEvent to load the overlay timeline - // with more correct position when main TL eventId is truthy - await this.overlayTimelineWindow.load(undefined, INITIAL_SIZE); - await this.extendOverlayWindowToCoverMainWindow(); - } - }); + const prom = this.timelineWindow.load(eventId, INITIAL_SIZE); this.buildLegacyCallEventGroupers(); this.setState({ events: [], @@ -1682,38 +1565,9 @@ class TimelinePanel extends React.Component { this.reloadEvents(); } - // get the list of events from the timeline windows and the pending event list + // get the list of events from the timeline window and the pending event list private getEvents(): Pick { - const mainEvents = this.timelineWindow!.getEvents(); - let overlayEvents = this.overlayTimelineWindow?.getEvents() ?? []; - if (this.props.overlayTimelineSetFilter !== undefined) { - overlayEvents = overlayEvents.filter(this.props.overlayTimelineSetFilter); - } - - // maintain the main timeline event order as returned from the HS - // merge overlay events at approximately the right position based on local timestamp - const events = overlayEvents.reduce( - (acc: MatrixEvent[], overlayEvent: MatrixEvent) => { - // find the first main tl event with a later timestamp - const index = acc.findIndex((event) => overlaysBefore(overlayEvent, event)); - // insert overlay event into timeline at approximately the right place - // if it's beyond the edge of the main window, hide it so that expanding - // the main window doesn't cause new events to pop in and change its position - if (index === -1) { - if (!this.timelineWindow!.canPaginate(EventTimeline.FORWARDS)) { - acc.push(overlayEvent); - } - } else if (index === 0) { - if (!this.timelineWindow!.canPaginate(EventTimeline.BACKWARDS)) { - acc.unshift(overlayEvent); - } - } else { - acc.splice(index, 0, overlayEvent); - } - return acc; - }, - [...mainEvents], - ); + const events = this.timelineWindow!.getEvents(); // We want the last event to be decrypted first const client = MatrixClientPeg.safeGet(); diff --git a/src/components/structures/ToastContainer.tsx b/src/components/structures/ToastContainer.tsx index 02db99a0e0..649cd1dc59 100644 --- a/src/components/structures/ToastContainer.tsx +++ b/src/components/structures/ToastContainer.tsx @@ -6,19 +6,20 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import * as React from "react"; +import React from "react"; import classNames from "classnames"; import { Text } from "@vector-im/compound-web"; +import { type EmptyObject } from "matrix-js-sdk/src/matrix"; -import ToastStore, { IToast } from "../../stores/ToastStore"; +import ToastStore, { type IToast } from "../../stores/ToastStore"; interface IState { toasts: IToast[]; countSeen: number; } -export default class ToastContainer extends React.Component<{}, IState> { - public constructor(props: {}) { +export default class ToastContainer extends React.Component { + public constructor(props: EmptyObject) { super(props); this.state = { toasts: ToastStore.sharedInstance().getToasts(), diff --git a/src/components/structures/UploadBar.tsx b/src/components/structures/UploadBar.tsx index 7618862798..a28ff2ba44 100644 --- a/src/components/structures/UploadBar.tsx +++ b/src/components/structures/UploadBar.tsx @@ -7,18 +7,18 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { Room, IEventRelation } from "matrix-js-sdk/src/matrix"; -import { Optional } from "matrix-events-sdk"; +import { type Room, type IEventRelation } from "matrix-js-sdk/src/matrix"; +import { type Optional } from "matrix-events-sdk"; import ContentMessages from "../../ContentMessages"; import dis from "../../dispatcher/dispatcher"; import { _t } from "../../languageHandler"; import { Action } from "../../dispatcher/actions"; import ProgressBar from "../views/elements/ProgressBar"; -import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; -import { RoomUpload } from "../../models/RoomUpload"; -import { ActionPayload } from "../../dispatcher/payloads"; -import { UploadPayload } from "../../dispatcher/payloads/UploadPayload"; +import AccessibleButton, { type ButtonEvent } from "../views/elements/AccessibleButton"; +import { type RoomUpload } from "../../models/RoomUpload"; +import { type ActionPayload } from "../../dispatcher/payloads"; +import { type UploadPayload } from "../../dispatcher/payloads/UploadPayload"; import { fileSize } from "../../utils/FileUtils"; interface IProps { diff --git a/src/components/structures/UserMenu.tsx b/src/components/structures/UserMenu.tsx index e266352393..67af61f7ac 100644 --- a/src/components/structures/UserMenu.tsx +++ b/src/components/structures/UserMenu.tsx @@ -6,24 +6,24 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { createRef, ReactNode } from "react"; -import { Room } from "matrix-js-sdk/src/matrix"; +import React, { type JSX, createRef, type ReactNode } from "react"; +import { type Room } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "../../MatrixClientPeg"; import defaultDispatcher from "../../dispatcher/dispatcher"; -import { ActionPayload } from "../../dispatcher/payloads"; +import { type ActionPayload } from "../../dispatcher/payloads"; import { Action } from "../../dispatcher/actions"; import { _t } from "../../languageHandler"; -import { ChevronFace, ContextMenuButton, MenuProps } from "./ContextMenu"; +import { ChevronFace, ContextMenuButton, type MenuProps } from "./ContextMenu"; import { UserTab } from "../views/dialogs/UserTab"; -import { OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; +import { type OpenToTabPayload } from "../../dispatcher/payloads/OpenToTabPayload"; import FeedbackDialog from "../views/dialogs/FeedbackDialog"; import Modal from "../../Modal"; import LogoutDialog, { shouldShowLogoutDialog } from "../views/dialogs/LogoutDialog"; import SettingsStore from "../../settings/SettingsStore"; import { findHighContrastTheme, getCustomTheme, isHighContrastTheme } from "../../theme"; import { RovingAccessibleButton } from "../../accessibility/RovingTabIndex"; -import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; +import AccessibleButton, { type ButtonEvent } from "../views/elements/AccessibleButton"; import SdkConfig from "../../SdkConfig"; import { getHomePageUrl } from "../../utils/pages"; import { OwnProfileStore } from "../../stores/OwnProfileStore"; @@ -39,7 +39,7 @@ import SpaceStore from "../../stores/spaces/SpaceStore"; import { UPDATE_SELECTED_SPACE } from "../../stores/spaces"; import UserIdentifierCustomisations from "../../customisations/UserIdentifier"; import PosthogTrackers from "../../PosthogTrackers"; -import { ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload"; +import { type ViewHomePagePayload } from "../../dispatcher/payloads/ViewHomePagePayload"; import { SDKContext } from "../../contexts/SDKContext"; import { shouldShowFeedback } from "../../utils/Feedback"; import DarkLightModeSvg from "../../../res/img/element-icons/roomlist/dark-light-mode.svg"; @@ -81,10 +81,10 @@ export default class UserMenu extends React.Component { private dispatcherRef?: string; private themeWatcherRef?: string; private readonly dndWatcherRef?: string; - private buttonRef: React.RefObject = createRef(); + private buttonRef = createRef(); - public constructor(props: IProps, context: React.ContextType) { - super(props, context); + public constructor(props: IProps) { + super(props); this.state = { contextMenuPosition: null, @@ -370,6 +370,13 @@ export default class UserMenu extends React.Component { ? toRightOf(this.state.contextMenuPosition) : below(this.state.contextMenuPosition); + const userIdentifierString = UserIdentifierCustomisations.getDisplayUserIdentifier( + MatrixClientPeg.safeGet().getSafeUserId(), + { + withDisplayName: true, + }, + ); + return (
    @@ -377,13 +384,8 @@ export default class UserMenu extends React.Component { {OwnProfileStore.instance.displayName} - - {UserIdentifierCustomisations.getDisplayUserIdentifier( - MatrixClientPeg.safeGet().getSafeUserId(), - { - withDisplayName: true, - }, - )} + + {userIdentifierString}
    diff --git a/src/components/structures/UserView.tsx b/src/components/structures/UserView.tsx index 7325f607ad..58aed9932b 100644 --- a/src/components/structures/UserView.tsx +++ b/src/components/structures/UserView.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { MatrixEvent, RoomMember, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { MatrixEvent, RoomMember, type MatrixClient } from "matrix-js-sdk/src/matrix"; import Modal from "../../Modal"; import { _t } from "../../languageHandler"; @@ -15,7 +15,7 @@ import ErrorDialog from "../views/dialogs/ErrorDialog"; import MainSplit from "./MainSplit"; import RightPanel from "./RightPanel"; import Spinner from "../views/elements/Spinner"; -import ResizeNotifier from "../../utils/ResizeNotifier"; +import type ResizeNotifier from "../../utils/ResizeNotifier"; import { RightPanelPhases } from "../../stores/right-panel/RightPanelStorePhases"; import HomePage from "./HomePage.tsx"; import MatrixClientContext from "../../contexts/MatrixClientContext"; @@ -34,8 +34,8 @@ export default class UserView extends React.Component { public static contextType = MatrixClientContext; declare public context: React.ContextType; - public constructor(props: IProps, context: React.ContextType) { - super(props, context); + public constructor(props: IProps) { + super(props); this.state = { loading: true, }; diff --git a/src/components/structures/ViewSource.tsx b/src/components/structures/ViewSource.tsx index eca37842d7..b2e003284b 100644 --- a/src/components/structures/ViewSource.tsx +++ b/src/components/structures/ViewSource.tsx @@ -7,8 +7,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React from "react"; -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import React, { type JSX } from "react"; +import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; import SyntaxHighlight from "../views/elements/SyntaxHighlight"; import { _t } from "../../languageHandler"; diff --git a/src/components/structures/WaitingForThirdPartyRoomView.tsx b/src/components/structures/WaitingForThirdPartyRoomView.tsx index 7787a03bf2..3983b286f1 100644 --- a/src/components/structures/WaitingForThirdPartyRoomView.tsx +++ b/src/components/structures/WaitingForThirdPartyRoomView.tsx @@ -6,12 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { RefObject } from "react"; -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import React, { type RefObject } from "react"; +import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; -import ResizeNotifier from "../../utils/ResizeNotifier"; +import type ResizeNotifier from "../../utils/ResizeNotifier"; import ErrorBoundary from "../views/elements/ErrorBoundary"; -import RoomHeader from "../views/rooms/RoomHeader"; +import RoomHeader from "../views/rooms/RoomHeader/RoomHeader.tsx"; import ScrollPanel from "./ScrollPanel"; import EventTileBubble from "../views/messages/EventTileBubble"; import NewRoomIntro from "../views/rooms/NewRoomIntro"; @@ -21,7 +21,7 @@ import SdkConfig from "../../SdkConfig"; import { useScopedRoomContext } from "../../contexts/ScopedRoomContext.tsx"; interface Props { - roomView: RefObject; + roomView: RefObject; resizeNotifier: ResizeNotifier; inviteEvent: MatrixEvent; } diff --git a/src/components/structures/auth/CompleteSecurity.tsx b/src/components/structures/auth/CompleteSecurity.tsx index a0f2be8836..5bade7b24a 100644 --- a/src/components/structures/auth/CompleteSecurity.tsx +++ b/src/components/structures/auth/CompleteSecurity.tsx @@ -78,9 +78,6 @@ export default class CompleteSecurity extends React.Component { } else if (phase === Phase.Busy) { icon = ; title = _t("encryption|verification|after_new_login|verify_this_device"); - } else if (phase === Phase.ConfirmReset) { - icon = ; - title = _t("encryption|verification|after_new_login|reset_confirmation"); } else if (phase === Phase.Finished) { // SetupEncryptionBody will take care of calling onFinished, we don't need to do anything } else { @@ -90,7 +87,7 @@ export default class CompleteSecurity extends React.Component { const forceVerification = SdkConfig.get("force_verification"); let skipButton; - if (!forceVerification && (phase === Phase.Intro || phase === Phase.ConfirmReset)) { + if (!forceVerification && phase === Phase.Intro) { skipButton = ( { label={_td("auth|change_password_new_label")} value={this.state.password} minScore={PASSWORD_MIN_SCORE} - fieldRef={(field) => (this.fieldPassword = field)} + fieldRef={(field) => { + this.fieldPassword = field; + }} onChange={this.onInputChanged.bind(this, "password")} autoComplete="new-password" /> @@ -399,7 +401,9 @@ export default class ForgotPassword extends React.Component { labelInvalid={_td("auth|reset_password|passwords_mismatch")} value={this.state.password2} password={this.state.password} - fieldRef={(field) => (this.fieldPasswordConfirm = field)} + fieldRef={(field) => { + this.fieldPasswordConfirm = field; + }} onChange={this.onInputChanged.bind(this, "password2")} autoComplete="new-password" /> diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index b4b41f0515..9aca0046f2 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -6,20 +6,20 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ReactNode } from "react"; +import React, { type JSX, type ReactNode } from "react"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; -import { SSOFlow, SSOAction } from "matrix-js-sdk/src/matrix"; +import { type SSOFlow, SSOAction } from "matrix-js-sdk/src/matrix"; import { _t, UserFriendlyError } from "../../../languageHandler"; -import Login, { ClientLoginFlow, OidcNativeFlow } from "../../../Login"; +import Login, { type ClientLoginFlow, type OidcNativeFlow } from "../../../Login"; import { messageForConnectionError, messageForLoginError } from "../../../utils/ErrorUtils"; import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; import AuthPage from "../../views/auth/AuthPage"; import PlatformPeg from "../../../PlatformPeg"; import SettingsStore from "../../../settings/SettingsStore"; import { UIFeature } from "../../../settings/UIFeature"; -import { IMatrixClientCreds } from "../../../MatrixClientPeg"; +import { type IMatrixClientCreds } from "../../../MatrixClientPeg"; import PasswordLogin from "../../views/auth/PasswordLogin"; import InlineSpinner from "../../views/elements/InlineSpinner"; import Spinner from "../../views/elements/Spinner"; @@ -27,8 +27,8 @@ import SSOButtons from "../../views/elements/SSOButtons"; import ServerPicker from "../../views/elements/ServerPicker"; import AuthBody from "../../views/auth/AuthBody"; import AuthHeader from "../../views/auth/AuthHeader"; -import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; -import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig"; +import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton"; +import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig"; import { filterBoolean } from "../../../utils/arrays"; import { startOidcLogin } from "../../../utils/oidc/authorize"; diff --git a/src/components/structures/auth/LoginSplashView.tsx b/src/components/structures/auth/LoginSplashView.tsx index 3d68a12e8d..aae7980185 100644 --- a/src/components/structures/auth/LoginSplashView.tsx +++ b/src/components/structures/auth/LoginSplashView.tsx @@ -6,14 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React from "react"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import React, { type JSX } from "react"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { CryptoEvent } from "matrix-js-sdk/src/crypto-api"; import { messageForSyncError } from "../../../utils/ErrorUtils"; import Spinner from "../../views/elements/Spinner"; import ProgressBar from "../../views/elements/ProgressBar"; -import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; +import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton"; import { _t } from "../../../languageHandler"; import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; import SdkConfig from "../../../SdkConfig"; @@ -43,13 +43,13 @@ type MigrationState = { /** * The view that is displayed after we have logged in, before the first /sync is completed. */ -export function LoginSplashView(props: Props): React.JSX.Element { +export function LoginSplashView(props: Props): JSX.Element { const migrationState = useTypedEventEmitterState( props.matrixClient, CryptoEvent.LegacyCryptoStoreMigrationProgress, (progress?: number, total?: number): MigrationState => ({ progress: progress ?? -1, totalSteps: total ?? -1 }), ); - let errorBox: React.JSX.Element | undefined; + let errorBox: JSX.Element | undefined; if (props.syncError) { errorBox =
    {messageForSyncError(props.syncError)}
    ; } diff --git a/src/components/structures/auth/Registration.tsx b/src/components/structures/auth/Registration.tsx index 1dc6f57bc7..a5c713b3ea 100644 --- a/src/components/structures/auth/Registration.tsx +++ b/src/components/structures/auth/Registration.tsx @@ -9,18 +9,18 @@ Please see LICENSE files in the repository root for full details. import { AuthType, createClient, - IAuthData, - AuthDict, - IInputs, + type IAuthData, + type AuthDict, + type IInputs, MatrixError, - IRegisterRequestParams, - IRequestTokenResponse, - MatrixClient, - SSOFlow, + type IRegisterRequestParams, + type IRequestTokenResponse, + type MatrixClient, + type SSOFlow, SSOAction, - RegisterResponse, + type RegisterResponse, } from "matrix-js-sdk/src/matrix"; -import React, { Fragment, ReactNode } from "react"; +import React, { type JSX, Fragment, type ReactNode } from "react"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; @@ -28,22 +28,22 @@ import { _t } from "../../../languageHandler"; import { adminContactStrings, messageForResourceLimitError, resourceLimitStrings } from "../../../utils/ErrorUtils"; import AutoDiscoveryUtils from "../../../utils/AutoDiscoveryUtils"; import * as Lifecycle from "../../../Lifecycle"; -import { IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg"; +import { type IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg"; import AuthPage from "../../views/auth/AuthPage"; -import Login, { OidcNativeFlow } from "../../../Login"; +import Login, { type OidcNativeFlow } from "../../../Login"; import dis from "../../../dispatcher/dispatcher"; import SSOButtons from "../../views/elements/SSOButtons"; import ServerPicker from "../../views/elements/ServerPicker"; import RegistrationForm from "../../views/auth/RegistrationForm"; -import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; +import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton"; import AuthBody from "../../views/auth/AuthBody"; import AuthHeader from "../../views/auth/AuthHeader"; -import InteractiveAuth, { InteractiveAuthCallback } from "../InteractiveAuth"; +import InteractiveAuth, { type InteractiveAuthCallback } from "../InteractiveAuth"; import Spinner from "../../views/elements/Spinner"; import { AuthHeaderDisplay } from "./header/AuthHeaderDisplay"; import { AuthHeaderProvider } from "./header/AuthHeaderProvider"; import SettingsStore from "../../../settings/SettingsStore"; -import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig"; +import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig"; import { startOidcLogin } from "../../../utils/oidc/authorize"; const debuglog = (...args: any[]): void => { diff --git a/src/components/structures/auth/SessionLockStolenView.tsx b/src/components/structures/auth/SessionLockStolenView.tsx index 01193440fc..bb95f1b44a 100644 --- a/src/components/structures/auth/SessionLockStolenView.tsx +++ b/src/components/structures/auth/SessionLockStolenView.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React from "react"; +import React, { type JSX } from "react"; import SplashPage from "../SplashPage"; import { _t } from "../../../languageHandler"; diff --git a/src/components/structures/auth/SetupEncryptionBody.tsx b/src/components/structures/auth/SetupEncryptionBody.tsx index 1ff0ad4120..b76a623e2c 100644 --- a/src/components/structures/auth/SetupEncryptionBody.tsx +++ b/src/components/structures/auth/SetupEncryptionBody.tsx @@ -1,15 +1,15 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 New Vector Ltd. Copyright 2020, 2021 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ -import React from "react"; -import { KeyBackupInfo, VerificationRequest } from "matrix-js-sdk/src/crypto-api"; +import React, { type JSX } from "react"; +import { type KeyBackupInfo, type VerificationRequest } from "matrix-js-sdk/src/crypto-api"; import { logger } from "matrix-js-sdk/src/logger"; -import { SecretStorageKeyDescription } from "matrix-js-sdk/src/secret-storage"; +import { type SecretStorageKeyDescription } from "matrix-js-sdk/src/secret-storage"; import { _t } from "../../../languageHandler"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; @@ -17,8 +17,9 @@ import Modal from "../../../Modal"; import VerificationRequestDialog from "../../views/dialogs/VerificationRequestDialog"; import { SetupEncryptionStore, Phase } from "../../../stores/SetupEncryptionStore"; import EncryptionPanel from "../../views/right_panel/EncryptionPanel"; -import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleButton"; +import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton"; import Spinner from "../../views/elements/Spinner"; +import { ResetIdentityDialog } from "../../views/dialogs/ResetIdentityDialog"; function keyHasPassphrase(keyInfo: SecretStorageKeyDescription): boolean { return Boolean(keyInfo.passphrase && keyInfo.passphrase.salt && keyInfo.passphrase.iterations); @@ -89,14 +90,15 @@ export default class SetupEncryptionBody extends React.Component // We need to call onFinished now to close this dialog, and // again later to signal that the verification is complete. this.props.onFinished(); - Modal.createDialog(VerificationRequestDialog, { + const { finished: verificationFinished } = Modal.createDialog(VerificationRequestDialog, { verificationRequestPromise: requestPromise, member: cli.getUser(userId) ?? undefined, - onFinished: async (): Promise => { - const request = await requestPromise; - request.cancel(); - this.props.onFinished(); - }, + }); + + verificationFinished.then(async () => { + const request = await requestPromise; + request.cancel(); + this.props.onFinished(); }); }; @@ -112,19 +114,15 @@ export default class SetupEncryptionBody extends React.Component private onResetClick = (ev: ButtonEvent): void => { ev.preventDefault(); - const store = SetupEncryptionStore.sharedInstance(); - store.reset(); - }; - - private onResetConfirmClick = (): void => { - this.props.onFinished(); - const store = SetupEncryptionStore.sharedInstance(); - store.resetConfirm(); - }; - - private onResetBackClick = (): void => { - const store = SetupEncryptionStore.sharedInstance(); - store.returnAfterReset(); + Modal.createDialog(ResetIdentityDialog, { + onReset: () => { + // The user completed the reset process - close this dialog + this.props.onFinished(); + const store = SetupEncryptionStore.sharedInstance(); + store.done(); + }, + variant: "confirm", + }); }; private onDoneClick = (): void => { @@ -157,7 +155,7 @@ export default class SetupEncryptionBody extends React.Component

    {_t("encryption|verification|no_key_or_device")}

    - + {_t("encryption|verification|reset_proceed_prompt")}
    @@ -246,22 +244,6 @@ export default class SetupEncryptionBody extends React.Component
    ); - } else if (phase === Phase.ConfirmReset) { - return ( -
    -

    {_t("encryption|verification|verify_reset_warning_1")}

    -

    {_t("encryption|verification|verify_reset_warning_2")}

    - -
    - - {_t("encryption|verification|reset_proceed_prompt")} - - - {_t("action|go_back")} - -
    -
    - ); } else if (phase === Phase.Busy || phase === Phase.Loading) { return ; } else { diff --git a/src/components/structures/auth/SoftLogout.tsx b/src/components/structures/auth/SoftLogout.tsx index 696edc0ad2..34fabe46c7 100644 --- a/src/components/structures/auth/SoftLogout.tsx +++ b/src/components/structures/auth/SoftLogout.tsx @@ -6,16 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ChangeEvent, SyntheticEvent } from "react"; +import React, { type JSX, type ChangeEvent, type SyntheticEvent } from "react"; import { logger } from "matrix-js-sdk/src/logger"; -import { Optional } from "matrix-events-sdk"; -import { LoginFlow, MatrixError, SSOAction, SSOFlow } from "matrix-js-sdk/src/matrix"; +import { type Optional } from "matrix-events-sdk"; +import { type LoginFlow, MatrixError, SSOAction, type SSOFlow } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../languageHandler"; import dis from "../../../dispatcher/dispatcher"; import * as Lifecycle from "../../../Lifecycle"; import Modal from "../../../Modal"; -import { IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg"; +import { type IMatrixClientCreds, MatrixClientPeg } from "../../../MatrixClientPeg"; import { sendLoginRequest } from "../../../Login"; import AuthPage from "../../views/auth/AuthPage"; import { SSO_HOMESERVER_URL_KEY, SSO_ID_SERVER_URL_KEY } from "../../../BasePlatform"; @@ -66,8 +66,8 @@ export default class SoftLogout extends React.Component { public static contextType = SDKContext; declare public context: React.ContextType; - public constructor(props: IProps, context: React.ContextType) { - super(props, context); + public constructor(props: IProps) { + super(props); this.state = { loginView: LoginView.Loading, @@ -89,13 +89,12 @@ export default class SoftLogout extends React.Component { } private onClearAll = (): void => { - Modal.createDialog(ConfirmWipeDeviceDialog, { - onFinished: (wipeData) => { - if (!wipeData) return; + const { finished } = Modal.createDialog(ConfirmWipeDeviceDialog); + finished.then(([wipeData]) => { + if (!wipeData) return; - logger.log("Clearing data from soft-logged-out session"); - Lifecycle.logout(this.context.oidcClientStore); - }, + logger.log("Clearing data from soft-logged-out session"); + Lifecycle.logout(this.context.oidcClientStore); }); }; @@ -168,7 +167,7 @@ export default class SoftLogout extends React.Component { return; } - Lifecycle.setLoggedIn(credentials).catch((e) => { + Lifecycle.hydrateSession(credentials).catch((e) => { logger.error(e); this.setState({ busy: false, errorText: _t("auth|failed_soft_logout_auth") }); }); @@ -204,7 +203,7 @@ export default class SoftLogout extends React.Component { return false; } - return Lifecycle.setLoggedIn(credentials) + return Lifecycle.hydrateSession(credentials) .then(() => { if (this.props.onTokenLoginCompleted) { this.props.onTokenLoginCompleted(); diff --git a/src/components/structures/auth/forgot-password/CheckEmail.tsx b/src/components/structures/auth/forgot-password/CheckEmail.tsx index 428bc17026..64036507de 100644 --- a/src/components/structures/auth/forgot-password/CheckEmail.tsx +++ b/src/components/structures/auth/forgot-password/CheckEmail.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ReactNode } from "react"; +import React, { type ReactNode } from "react"; import { Tooltip } from "@vector-im/compound-web"; import { RestartIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; diff --git a/src/components/structures/auth/forgot-password/EnterEmail.tsx b/src/components/structures/auth/forgot-password/EnterEmail.tsx index d50040552d..9e7d6ae5a6 100644 --- a/src/components/structures/auth/forgot-password/EnterEmail.tsx +++ b/src/components/structures/auth/forgot-password/EnterEmail.tsx @@ -6,15 +6,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ReactNode, useRef } from "react"; +import React, { type ReactNode, useRef } from "react"; import { EmailSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { _t, _td } from "../../../../languageHandler"; import EmailField from "../../../views/auth/EmailField"; import { ErrorMessage } from "../../ErrorMessage"; import Spinner from "../../../views/elements/Spinner"; -import Field from "../../../views/elements/Field"; -import AccessibleButton, { ButtonEvent } from "../../../views/elements/AccessibleButton"; +import type Field from "../../../views/elements/Field"; +import AccessibleButton, { type ButtonEvent } from "../../../views/elements/AccessibleButton"; interface EnterEmailProps { email: string; diff --git a/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx b/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx index cb2c5b3b85..5f57146fe5 100644 --- a/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx +++ b/src/components/structures/auth/forgot-password/VerifyEmailModal.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ReactNode } from "react"; +import React, { type ReactNode } from "react"; import { Tooltip } from "@vector-im/compound-web"; import { RestartIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; diff --git a/src/components/structures/auth/header/AuthHeaderContext.tsx b/src/components/structures/auth/header/AuthHeaderContext.tsx index 4c9d436f0c..a45233564b 100644 --- a/src/components/structures/auth/header/AuthHeaderContext.tsx +++ b/src/components/structures/auth/header/AuthHeaderContext.tsx @@ -6,10 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { createContext, Dispatch, ReducerAction, ReducerState } from "react"; +import { createContext, type Dispatch, type Reducer, type ReducerState } from "react"; import type { AuthHeaderReducer } from "./AuthHeaderProvider"; +type ReducerAction> = R extends Reducer ? A : never; + interface AuthHeaderContextType { state: ReducerState; dispatch: Dispatch>; diff --git a/src/components/structures/auth/header/AuthHeaderDisplay.tsx b/src/components/structures/auth/header/AuthHeaderDisplay.tsx index f1289a1c99..515d919db3 100644 --- a/src/components/structures/auth/header/AuthHeaderDisplay.tsx +++ b/src/components/structures/auth/header/AuthHeaderDisplay.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { Fragment, PropsWithChildren, ReactNode, useContext } from "react"; +import React, { type JSX, Fragment, type PropsWithChildren, type ReactNode, useContext } from "react"; import { AuthHeaderContext } from "./AuthHeaderContext"; diff --git a/src/components/structures/auth/header/AuthHeaderModifier.tsx b/src/components/structures/auth/header/AuthHeaderModifier.tsx index d3b3d648e7..afe5a4b7ce 100644 --- a/src/components/structures/auth/header/AuthHeaderModifier.tsx +++ b/src/components/structures/auth/header/AuthHeaderModifier.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { ReactNode, useContext, useEffect } from "react"; +import { type ReactNode, useContext, useEffect } from "react"; import { AuthHeaderContext } from "./AuthHeaderContext"; import { AuthHeaderActionType } from "./AuthHeaderProvider"; diff --git a/src/components/structures/auth/header/AuthHeaderProvider.tsx b/src/components/structures/auth/header/AuthHeaderProvider.tsx index 0189b69212..50a53b95c3 100644 --- a/src/components/structures/auth/header/AuthHeaderProvider.tsx +++ b/src/components/structures/auth/header/AuthHeaderProvider.tsx @@ -7,10 +7,10 @@ Please see LICENSE files in the repository root for full details. */ import { isEqual } from "lodash"; -import React, { ComponentProps, PropsWithChildren, Reducer, useReducer } from "react"; +import React, { type JSX, type ComponentProps, type PropsWithChildren, type Reducer, useReducer } from "react"; import { AuthHeaderContext } from "./AuthHeaderContext"; -import { AuthHeaderModifier } from "./AuthHeaderModifier"; +import { type AuthHeaderModifier } from "./AuthHeaderModifier"; export enum AuthHeaderActionType { Add, @@ -24,8 +24,8 @@ interface AuthHeaderAction { export type AuthHeaderReducer = Reducer[], AuthHeaderAction>; -export function AuthHeaderProvider({ children }: PropsWithChildren<{}>): JSX.Element { - const [state, dispatch] = useReducer( +export function AuthHeaderProvider({ children }: PropsWithChildren): JSX.Element { + const [state, dispatch] = useReducer[], [AuthHeaderAction]>( (state: ComponentProps[], action: AuthHeaderAction) => { switch (action.type) { case AuthHeaderActionType.Add: diff --git a/src/components/structures/grouper/BaseGrouper.ts b/src/components/structures/grouper/BaseGrouper.ts index a685582a2c..c0fc83080e 100644 --- a/src/components/structures/grouper/BaseGrouper.ts +++ b/src/components/structures/grouper/BaseGrouper.ts @@ -6,10 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { ReactNode } from "react"; -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { type ReactNode } from "react"; +import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; -import MessagePanel, { WrappedEvent } from "../MessagePanel"; +import { type WrappedEvent } from "../MessagePanel"; +import type MessagePanel from "../MessagePanel"; /* Grouper classes determine when events can be grouped together in a summary. * Groupers should have the following methods: diff --git a/src/components/structures/grouper/CreationGrouper.tsx b/src/components/structures/grouper/CreationGrouper.tsx index 5009b14baf..009f5bdc26 100644 --- a/src/components/structures/grouper/CreationGrouper.tsx +++ b/src/components/structures/grouper/CreationGrouper.tsx @@ -6,12 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ReactNode } from "react"; -import { EventType, M_BEACON_INFO, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import React, { type ReactNode } from "react"; +import { EventType, M_BEACON_INFO, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { BaseGrouper } from "./BaseGrouper"; -import MessagePanel, { WrappedEvent } from "../MessagePanel"; +import { type WrappedEvent } from "../MessagePanel"; +import type MessagePanel from "../MessagePanel"; import DMRoomMap from "../../../utils/DMRoomMap"; import { _t } from "../../../languageHandler"; import DateSeparator from "../../views/messages/DateSeparator"; diff --git a/src/components/structures/grouper/LateEventGrouper.ts b/src/components/structures/grouper/LateEventGrouper.ts index 87cf6549b2..73e858b340 100644 --- a/src/components/structures/grouper/LateEventGrouper.ts +++ b/src/components/structures/grouper/LateEventGrouper.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; const UNSIGNED_KEY = "io.element.late_event"; diff --git a/src/components/structures/grouper/MainGrouper.tsx b/src/components/structures/grouper/MainGrouper.tsx index 84d0be2674..e686f1aa81 100644 --- a/src/components/structures/grouper/MainGrouper.tsx +++ b/src/components/structures/grouper/MainGrouper.tsx @@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ReactNode } from "react"; -import { EventType, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import React, { type ReactNode } from "react"; +import { EventType, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import type MessagePanel from "../MessagePanel"; import type { WrappedEvent } from "../MessagePanel"; diff --git a/src/components/utils/Box.tsx b/src/components/utils/Box.tsx index 2de64ba075..2c5dfa56d2 100644 --- a/src/components/utils/Box.tsx +++ b/src/components/utils/Box.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import classNames from "classnames"; -import React, { useMemo } from "react"; +import React, { type JSX, useMemo } from "react"; type FlexProps = { /** diff --git a/src/components/utils/Flex.tsx b/src/components/utils/Flex.tsx index 3788e32c45..0a8c3c2fa4 100644 --- a/src/components/utils/Flex.tsx +++ b/src/components/utils/Flex.tsx @@ -7,14 +7,14 @@ Please see LICENSE files in the repository root for full details. */ import classNames from "classnames"; -import React, { useMemo } from "react"; +import React, { type JSX, type ComponentProps, type JSXElementConstructor, useMemo } from "react"; -type FlexProps = { +type FlexProps> = { /** * The type of the HTML element * @default div */ - as?: string; + as?: T; /** * The CSS class name. */ @@ -30,15 +30,20 @@ type FlexProps = { */ direction?: "row" | "column" | "row-reverse" | "column-reverse"; /** - * The alingment of the flex children + * The alignment of the flex children * @default start */ - align?: "start" | "center" | "end" | "baseline" | "stretch"; + align?: "start" | "center" | "end" | "baseline" | "stretch" | "normal"; /** * The justification of the flex children * @default start */ justify?: "start" | "center" | "end" | "space-between"; + /** + * The wrapping of the flex children + * @default nowrap + */ + wrap?: "wrap" | "nowrap" | "wrap-reverse"; /** * The spacing between the flex children, expressed with the CSS unit * @default 0 @@ -48,22 +53,23 @@ type FlexProps = { * the on click event callback */ onClick?: (e: React.MouseEvent) => void; -}; +} & ComponentProps; /** * A flexbox container helper */ -export function Flex({ +export function Flex = "div">({ as = "div", display = "flex", direction = "row", align = "start", justify = "start", gap = "0", + wrap = "nowrap", className, children, ...props -}: React.PropsWithChildren): JSX.Element { +}: React.PropsWithChildren>): JSX.Element { const style = useMemo( () => ({ "--mx-flex-display": display, @@ -71,8 +77,9 @@ export function Flex({ "--mx-flex-align": align, "--mx-flex-justify": justify, "--mx-flex-gap": gap, + "--mx-flex-wrap": wrap, }), - [align, direction, display, gap, justify], + [align, direction, display, gap, justify, wrap], ); return React.createElement(as, { ...props, className: classNames("mx_Flex", className), style }, children); diff --git a/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx b/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx new file mode 100644 index 0000000000..8879f5ae69 --- /dev/null +++ b/src/components/viewmodels/avatars/RoomAvatarViewModel.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { EventType, JoinRule, type MatrixEvent, type Room, RoomEvent } from "matrix-js-sdk/src/matrix"; +import { useEffect, useState } from "react"; + +import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; +import { useDmMember, usePresence, type Presence } from "../../views/avatars/WithPresenceIndicator"; + +export interface RoomAvatarViewState { + /** + * Whether the room avatar has a decoration. + * A decoration can be a public or a video call icon or an indicator of presence. + */ + hasDecoration: boolean; + /** + * Whether the room is public. + */ + isPublic: boolean; + /** + * Whether the room is a video room. + */ + isVideoRoom: boolean; + /** + * The presence of the user in the DM room. + * If null, the user is not in a DM room or presence is not enabled. + */ + presence: Presence | null; +} + +/** + * Hook to get the state of the room avatar. + * @param room + */ +export function useRoomAvatarViewModel(room: Room): RoomAvatarViewState { + const isVideoRoom = room.isElementVideoRoom() || room.isCallRoom(); + const roomMember = useDmMember(room); + const presence = usePresence(room, roomMember); + const isPublic = useIsPublic(room); + + const hasDecoration = isPublic || isVideoRoom || presence !== null; + + return { hasDecoration, isPublic, isVideoRoom, presence }; +} + +/** + * Hook listening to the room join rules. + * Return true if the room is public. + * @param room + */ +function useIsPublic(room: Room): boolean { + const [isPublic, setIsPublic] = useState(isRoomPublic(room)); + // We don't use `useTypedEventEmitterState` because we don't want to update `isPublic` value at every `RoomEvent.Timeline` event. + useTypedEventEmitter(room, RoomEvent.Timeline, (ev: MatrixEvent, _room: Room) => { + if (room.roomId !== _room.roomId) return; + if (ev.getType() !== EventType.RoomJoinRules && ev.getType() !== EventType.RoomMember) return; + + setIsPublic(isRoomPublic(_room)); + }); + + // Reset the value when the room changes + useEffect(() => { + setIsPublic(isRoomPublic(room)); + }, [room]); + + return isPublic; +} + +/** + * Whether the room is public. + * @param room + */ +function isRoomPublic(room: Room): boolean { + return room.getJoinRule() === JoinRule.Public; +} diff --git a/src/components/viewmodels/memberlist/MemberListViewModel.tsx b/src/components/viewmodels/memberlist/MemberListViewModel.tsx index 88eacb1b93..a03f703511 100644 --- a/src/components/viewmodels/memberlist/MemberListViewModel.tsx +++ b/src/components/viewmodels/memberlist/MemberListViewModel.tsx @@ -8,35 +8,35 @@ Please see LICENSE files in the repository root for full details. import { ClientEvent, EventType, - MatrixEvent, - Room, + type MatrixEvent, + type Room, RoomEvent, RoomMemberEvent, - RoomState, + type RoomState, RoomStateEvent, - RoomMember as SdkRoomMember, - User, + type RoomMember as SdkRoomMember, + type User, UserEvent, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { useCallback, useContext, useEffect, useMemo, useState } from "react"; import { throttle } from "lodash"; -import { RoomMember } from "../../../models/rooms/RoomMember"; +import { type RoomMember } from "../../../models/rooms/RoomMember"; import { mediaFromMxc } from "../../../customisations/Media"; import UserIdentifierCustomisations from "../../../customisations/UserIdentifier"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../settings/UIFeature"; -import { PresenceState } from "../../../models/rooms/PresenceState"; +import { type PresenceState } from "../../../models/rooms/PresenceState"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { SDKContext } from "../../../contexts/SDKContext"; import PosthogTrackers from "../../../PosthogTrackers"; -import { ButtonEvent } from "../../views/elements/AccessibleButton"; +import { type ButtonEvent } from "../../views/elements/AccessibleButton"; import { inviteToRoom } from "../../../utils/room/inviteToRoom"; import { canInviteTo } from "../../../utils/room/canInviteTo"; import { isValid3pidInvite } from "../../../RoomInvite"; -import { ThreePIDInvite } from "../../../models/rooms/ThreePIDInvite"; -import { XOR } from "../../../@types/common"; +import { type ThreePIDInvite } from "../../../models/rooms/ThreePIDInvite"; +import { type XOR } from "../../../@types/common"; import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; type Member = XOR<{ member: RoomMember }, { threePidInvite: ThreePIDInvite }>; @@ -99,8 +99,12 @@ export function sdkRoomMemberToRoomMember(member: SdkRoomMember): Member { }; } +export const SEPARATOR = "SEPARATOR"; +export type MemberWithSeparator = Member | typeof SEPARATOR; + export interface MemberListViewState { - members: Member[]; + members: MemberWithSeparator[]; + memberCount: number; search: (searchQuery: string) => void; isPresenceEnabled: boolean; shouldShowInvite: boolean; @@ -118,10 +122,16 @@ export function useMemberListViewModel(roomId: string): MemberListViewState { } const sdkContext = useContext(SDKContext); - const [memberMap, setMemberMap] = useState>(new Map()); + const [memberMap, setMemberMap] = useState>(new Map()); const [isLoading, setIsLoading] = useState(true); // This is the last known total number of members in this room. const [totalMemberCount, setTotalMemberCount] = useState(0); + /** + * This is the current number of members in the list. + * This number will be less than the total number of members + * in the room when the search functionality is used. + */ + const [memberCount, setMemberCount] = useState(0); const loadMembers = useMemo( () => @@ -131,24 +141,34 @@ export function useMemberListViewModel(roomId: string): MemberListViewState { roomId, searchQuery, ); - const newMemberMap = new Map(); - // First add the invited room members - for (const member of invitedSdk) { - const roomMember = sdkRoomMemberToRoomMember(member); - newMemberMap.set(member.userId, roomMember); - } - // Then add the third party invites const threePidInvited = getPending3PidInvites(room, searchQuery); - for (const invited of threePidInvited) { - const key = invited.threePidInvite!.event.getContent().display_name; - newMemberMap.set(key, invited); - } - // Finally add the joined room members + + const newMemberMap = new Map(); + + // First add the joined room members for (const member of joinedSdk) { const roomMember = sdkRoomMemberToRoomMember(member); newMemberMap.set(member.userId, roomMember); } + + // Then a separator if needed + if (joinedSdk.length > 0 && (invitedSdk.length > 0 || threePidInvited.length > 0)) + newMemberMap.set(SEPARATOR, SEPARATOR); + + // Then add the invited room members + for (const member of invitedSdk) { + const roomMember = sdkRoomMemberToRoomMember(member); + newMemberMap.set(member.userId, roomMember); + } + + // Finally add the third party invites + for (const invited of threePidInvited) { + const key = invited.threePidInvite!.event.getContent().display_name; + newMemberMap.set(key, invited); + } + setMemberMap(newMemberMap); + setMemberCount(joinedSdk.length + invitedSdk.length + threePidInvited.length); if (!searchQuery) { /** * Since searching for members only gives you the relevant @@ -241,6 +261,7 @@ export function useMemberListViewModel(roomId: string): MemberListViewState { return { members: Array.from(memberMap.values()), + memberCount, search: loadMembers, shouldShowInvite, isPresenceEnabled, diff --git a/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx b/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx index 367064e3ff..4f6814caae 100644 --- a/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx +++ b/src/components/viewmodels/memberlist/tiles/MemberTileViewModel.tsx @@ -6,16 +6,16 @@ Please see LICENSE files in the repository root for full details. */ import { useEffect, useMemo, useState } from "react"; -import { RoomStateEvent, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix"; -import { UserVerificationStatus, CryptoEvent } from "matrix-js-sdk/src/crypto-api"; +import { RoomStateEvent, type MatrixEvent, EventType } from "matrix-js-sdk/src/matrix"; +import { type UserVerificationStatus, CryptoEvent } from "matrix-js-sdk/src/crypto-api"; import dis from "../../../../dispatcher/dispatcher"; import { MatrixClientPeg } from "../../../../MatrixClientPeg"; import { Action } from "../../../../dispatcher/actions"; import { asyncSome } from "../../../../utils/arrays"; import { getUserDeviceIds } from "../../../../utils/crypto/deviceInfo"; -import { RoomMember } from "../../../../models/rooms/RoomMember"; -import { _t, _td, TranslationKey } from "../../../../languageHandler"; +import { type RoomMember } from "../../../../models/rooms/RoomMember"; +import { _t, _td, type TranslationKey } from "../../../../languageHandler"; import UserIdentifierCustomisations from "../../../../customisations/UserIdentifier"; import { E2EStatus } from "../../../../utils/ShieldUtils"; @@ -145,7 +145,7 @@ export function useMemberTileViewModel(props: MemberTileViewModelProps): MemberT userLabel = _t(PowerLabel[powerStatus]); } if (props.member.isInvite) { - userLabel = `(${_t("member_list|invited_label")})`; + userLabel = _t("member_list|invited_label"); } return { diff --git a/src/components/viewmodels/memberlist/tiles/ThreePidTileViewModel.tsx b/src/components/viewmodels/memberlist/tiles/ThreePidTileViewModel.tsx index daeb8d899f..f1e32692c0 100644 --- a/src/components/viewmodels/memberlist/tiles/ThreePidTileViewModel.tsx +++ b/src/components/viewmodels/memberlist/tiles/ThreePidTileViewModel.tsx @@ -7,7 +7,8 @@ Please see LICENSE files in the repository root for full details. import dis from "../../../../dispatcher/dispatcher"; import { Action } from "../../../../dispatcher/actions"; -import { ThreePIDInvite } from "../../../../models/rooms/ThreePIDInvite"; +import { type ThreePIDInvite } from "../../../../models/rooms/ThreePIDInvite"; +import { _t } from "../../../../languageHandler"; interface ThreePidTileViewModelProps { threePidInvite: ThreePIDInvite; @@ -16,6 +17,7 @@ interface ThreePidTileViewModelProps { export interface ThreePidTileViewState { name: string; onClick: () => void; + userLabel?: string; } export function useThreePidTileViewModel(props: ThreePidTileViewModelProps): ThreePidTileViewState { @@ -28,8 +30,11 @@ export function useThreePidTileViewModel(props: ThreePidTileViewModelProps): Thr }); }; + const userLabel = _t("member_list|invited_label"); + return { name, onClick, + userLabel, }; } diff --git a/src/components/viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx b/src/components/viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx new file mode 100644 index 0000000000..10cbc5b568 --- /dev/null +++ b/src/components/viewmodels/right_panel/RoomSummaryCardTopicViewModel.tsx @@ -0,0 +1,84 @@ +/* +Copyright 2025 New Vector Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import { type SyntheticEvent, useState } from "react"; +import { EventType, type Room, type ContentHelpers } from "matrix-js-sdk/src/matrix"; +import { type Optional } from "matrix-events-sdk"; + +import { useRoomState } from "../../../hooks/useRoomState"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import { onRoomTopicLinkClick } from "../../views/elements/RoomTopic"; +import { useTopic } from "../../../hooks/room/useTopic"; + +export interface RoomTopicState { + /** + * The topic of the room, the value is taken from the room state + */ + topic: Optional; + /** + * Whether the topic is expanded or not + */ + expanded: boolean; + /** + * Whether the user have the permission to edit the topic + */ + canEditTopic: boolean; + /** + * The callback when the edit button is clicked + */ + onEditClick: (e: SyntheticEvent) => void; + /** + * When the expand button is clicked, it changes expanded state + */ + onExpandedClick: (ev: SyntheticEvent) => void; + /** + * The callback when the topic link is clicked + */ + onTopicLinkClick: React.MouseEventHandler; +} + +/** + * The view model for the room topic used in the RoomSummaryCard + * @param room - the room to get the topic from + * @returns the room topic state + */ +export function useRoomTopicViewModel(room: Room): RoomTopicState { + const [expanded, setExpanded] = useState(true); + + const topic = useTopic(room); + + 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" }); + }; + + const onExpandedClick = (e: SyntheticEvent): void => { + e.preventDefault(); + e.stopPropagation(); + setExpanded((_expanded) => !_expanded); + }; + + const onTopicLinkClick = (e: React.MouseEvent): void => { + if (e.target instanceof HTMLAnchorElement) { + onRoomTopicLinkClick(e); + return; + } + }; + + return { + topic, + expanded, + canEditTopic, + onEditClick, + onExpandedClick, + onTopicLinkClick, + }; +} diff --git a/src/components/viewmodels/right_panel/RoomSummaryCardViewModel.tsx b/src/components/viewmodels/right_panel/RoomSummaryCardViewModel.tsx new file mode 100644 index 0000000000..143f3fca18 --- /dev/null +++ b/src/components/viewmodels/right_panel/RoomSummaryCardViewModel.tsx @@ -0,0 +1,278 @@ +/* +Copyright 2025 New Vector Ltd. +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only +Please see LICENSE files in the repository root for full details. +*/ + +import { useEffect, useRef, useState } from "react"; +import { EventType, type JoinRule, type Room, RoomStateEvent } from "matrix-js-sdk/src/matrix"; + +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; +import { useIsEncrypted } from "../../../hooks/useIsEncrypted"; +import { useScopedRoomContext } from "../../../contexts/ScopedRoomContext"; +import { type E2EStatus } from "../../../utils/ShieldUtils"; +import { isVideoRoom as calcIsVideoRoom } from "../../../utils/video-rooms"; +import { useRoomState } from "../../../hooks/useRoomState"; +import { useAccountData } from "../../../hooks/useAccountData"; +import { useDispatcher } from "../../../hooks/useDispatcher"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import { Action } from "../../../dispatcher/actions"; +import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore"; +import { canInviteTo } from "../../../utils/room/canInviteTo"; +import { DefaultTagID } from "../../../stores/room-list/models"; +import { useEventEmitterState } from "../../../hooks/useEventEmitter"; +import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; +import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; +import PosthogTrackers from "../../../PosthogTrackers"; +import { PollHistoryDialog } from "../../views/dialogs/PollHistoryDialog"; +import Modal from "../../../Modal"; +import ExportDialog from "../../views/dialogs/ExportDialog"; +import { ShareDialog } from "../../views/dialogs/ShareDialog"; +import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; +import { ReportRoomDialog } from "../../views/dialogs/ReportRoomDialog"; +import { Key } from "../../../Keyboard"; +import { usePinnedEvents } from "../../../hooks/usePinnedEvents"; +import { tagRoom } from "../../../utils/room/tagRoom"; +import { inviteToRoom } from "../../../utils/room/inviteToRoom"; + +export interface RoomSummaryCardState { + isDirectMessage: boolean; + /** + * Whether the room is encrypted, used to display the correct badge and icon + */ + isRoomEncrypted: boolean; + /** + * The e2e status of the room, used to display the correct badge and icon + */ + e2eStatus: E2EStatus | undefined; + /** + * The join rule of the room, used to display the correct badge and icon + */ + roomJoinRule: JoinRule; + /** + * if it is a video room, it should not display export chat, polls, files, extensions + */ + isVideoRoom: boolean; + /** + * display the alias of the room, if it exists + */ + alias: string; + /** + * value to check if the room is a favorite or not + */ + isFavorite: boolean; + /** + * value to check if we disable invite button or not + */ + canInviteToState: boolean; + /** + * Getting the number of pinned messages in the room, next to the pin button + */ + pinCount: number; + searchInputRef: React.RefObject; + /** + * The callback when new value is entered in the search input + */ + onUpdateSearchInput: (e: React.KeyboardEvent) => void; + /** + * Callbacks to all the actions button in the right panel + */ + onRoomMembersClick: () => void; + onRoomThreadsClick: () => void; + onRoomFilesClick: () => void; + onRoomExtensionsClick: () => void; + onRoomPinsClick: () => void; + onRoomSettingsClick: (ev: Event) => void; + onLeaveRoomClick: () => void; + onShareRoomClick: () => void; + onRoomExportClick: () => Promise; + onRoomPollHistoryClick: () => void; + onReportRoomClick: () => Promise; + onFavoriteToggleClick: () => void; + onInviteToRoomClick: () => void; +} + +/** + * Hook to check if the room is a direct message or not + * @param room - The room to check + * @returns Whether the room is a direct message + */ +const useIsDirectMessage = (room: Room): boolean => { + const directRoomsList = useAccountData>(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]); + + return isDirectMessage; +}; + +/** + * Hook to handle the search input in the right panel + * @param onSearchCancel - The callback when the search input is cancelled + * @returns The search input ref and the callback when the search input is updated + */ +const useSearchInput = ( + onSearchCancel?: () => void, +): { + searchInputRef: React.RefObject; + onUpdateSearchInput: (e: React.KeyboardEvent) => void; +} => { + const searchInputRef = useRef(null); + + const onUpdateSearchInput = (e: React.KeyboardEvent): void => { + if (searchInputRef.current && e.key === Key.ESCAPE) { + searchInputRef.current.value = ""; + onSearchCancel?.(); + } + }; + + // Focus the search field when the user clicks on the search button component + useDispatcher(defaultDispatcher, (payload) => { + if (payload.action === Action.FocusMessageSearch) { + searchInputRef.current?.focus(); + } + }); + + return { + searchInputRef, + onUpdateSearchInput, + }; +}; + +export function useRoomSummaryCardViewModel( + room: Room, + permalinkCreator: RoomPermalinkCreator, + onSearchCancel?: () => void, +): RoomSummaryCardState { + const cli = useMatrixClientContext(); + + const isRoomEncrypted = useIsEncrypted(cli, room) ?? false; + const roomContext = useScopedRoomContext("e2eStatus", "timelineRenderingType"); + const e2eStatus = roomContext.e2eStatus; + const isVideoRoom = calcIsVideoRoom(room); + + const roomState = useRoomState(room); + // used to check if the room is public or not + const roomJoinRule = roomState.getJoinRule(); + const alias = room.getCanonicalAlias() || room.getAltAliases()[0] || ""; + const pinCount = usePinnedEvents(room).length; + // value to check if the user can invite to the room + const canInviteToState = useEventEmitterState(room, RoomStateEvent.Update, () => canInviteTo(room)); + + const roomTags = useEventEmitterState(RoomListStore.instance, LISTS_UPDATE_EVENT, () => + RoomListStore.instance.getTagsForRoom(room), + ); + const isFavorite = roomTags.includes(DefaultTagID.Favourite); + + const isDirectMessage = useIsDirectMessage(room); + + const onRoomMembersClick = (): void => { + RightPanelStore.instance.pushCard({ phase: RightPanelPhases.MemberList }, 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 onShareRoomClick = (): void => { + Modal.createDialog(ShareDialog, { + target: room, + }); + }; + + const onRoomExportClick = async (): Promise => { + 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 onReportRoomClick = async (): Promise => { + const [leave] = await Modal.createDialog(ReportRoomDialog, { + roomId: room.roomId, + }).finished; + if (leave) { + defaultDispatcher.dispatch({ + action: "leave_room", + room_id: room.roomId, + }); + } + }; + + const onFavoriteToggleClick = (): void => { + tagRoom(room, DefaultTagID.Favourite); + }; + + const onInviteToRoomClick = (): void => { + inviteToRoom(room); + }; + + // Room Search element ref + const { searchInputRef, onUpdateSearchInput } = useSearchInput(onSearchCancel); + + return { + isDirectMessage, + isRoomEncrypted, + roomJoinRule, + e2eStatus, + isVideoRoom, + alias, + isFavorite, + canInviteToState, + searchInputRef, + pinCount, + onRoomMembersClick, + onRoomThreadsClick, + onRoomFilesClick, + onRoomExtensionsClick, + onRoomPinsClick, + onRoomSettingsClick, + onLeaveRoomClick, + onShareRoomClick, + onRoomExportClick, + onRoomPollHistoryClick, + onReportRoomClick, + onUpdateSearchInput, + onFavoriteToggleClick, + onInviteToRoomClick, + }; +} diff --git a/src/components/viewmodels/roomlist/MessagePreviewViewModel.tsx b/src/components/viewmodels/roomlist/MessagePreviewViewModel.tsx new file mode 100644 index 0000000000..9e141c1379 --- /dev/null +++ b/src/components/viewmodels/roomlist/MessagePreviewViewModel.tsx @@ -0,0 +1,57 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { useCallback, useEffect, useState } from "react"; + +import type { Room } from "matrix-js-sdk/src/matrix"; +import { type MessagePreview, MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; +import { useEventEmitter } from "../../../hooks/useEventEmitter"; + +interface MessagePreviewViewState { + /** + * A string representation of the message preview if available. + */ + message?: string; +} + +/** + * View model for rendering a message preview for a given room list item. + * @param room The room for which we're rendering the message preview. + * @see {@link MessagePreviewViewState} for what this view model returns. + */ +export function useMessagePreviewViewModel(room: Room): MessagePreviewViewState { + const [messagePreview, setMessagePreview] = useState(null); + + const updatePreview = useCallback(async (): Promise => { + /** + * The second argument to getPreviewForRoom is a tag id which doesn't really make + * much sense within the context of the new room list. We can pass an empty string + * to match all tags for now but we should remember to actually change the implementation + * in the store once we remove the legacy room list. + */ + const newPreview = await MessagePreviewStore.instance.getPreviewForRoom(room, ""); + setMessagePreview(newPreview); + }, [room]); + + /** + * Update when the message preview has changed for this room. + */ + useEventEmitter(MessagePreviewStore.instance, MessagePreviewStore.getPreviewChangedEventName(room), () => { + updatePreview(); + }); + + /** + * Do an initial fetch of the message preview. + */ + useEffect(() => { + updatePreview(); + }, [updatePreview]); + + return { + message: messagePreview?.text, + }; +} diff --git a/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx b/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx new file mode 100644 index 0000000000..d6f54fcae7 --- /dev/null +++ b/src/components/viewmodels/roomlist/RoomListHeaderViewModel.tsx @@ -0,0 +1,236 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { useCallback } from "react"; +import { JoinRule, type Room, RoomEvent, RoomType } from "matrix-js-sdk/src/matrix"; + +import { useFeatureEnabled } from "../../../hooks/useSettings"; +import defaultDispatcher from "../../../dispatcher/dispatcher"; +import PosthogTrackers from "../../../PosthogTrackers"; +import { Action } from "../../../dispatcher/actions"; +import { useEventEmitterState, useTypedEventEmitterState } from "../../../hooks/useEventEmitter"; +import { + getMetaSpaceName, + type MetaSpace, + type SpaceKey, + UPDATE_HOME_BEHAVIOUR, + UPDATE_SELECTED_SPACE, +} from "../../../stores/spaces"; +import SpaceStore from "../../../stores/spaces/SpaceStore"; +import { + shouldShowSpaceSettings, + showCreateNewRoom, + showSpaceInvite, + showSpacePreferences, + showSpaceSettings, +} from "../../../utils/space"; +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; +import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { createRoom, hasCreateRoomRights } from "./utils"; +import { type SortOption, useSorter } from "./useSorter"; +import { useMessagePreviewToggle } from "./useMessagePreviewToggle"; + +/** + * Hook to get the active space and its title. + */ +function useSpace(): { activeSpace: Room | null; title: string } { + const [spaceKey, activeSpace] = useEventEmitterState<[SpaceKey, Room | null]>( + SpaceStore.instance, + UPDATE_SELECTED_SPACE, + () => [SpaceStore.instance.activeSpace, SpaceStore.instance.activeSpaceRoom], + ); + const spaceName = useTypedEventEmitterState(activeSpace ?? undefined, RoomEvent.Name, () => activeSpace?.name); + const allRoomsInHome = useEventEmitterState( + SpaceStore.instance, + UPDATE_HOME_BEHAVIOUR, + () => SpaceStore.instance.allRoomsInHome, + ); + + const title = spaceName ?? getMetaSpaceName(spaceKey as MetaSpace, allRoomsInHome); + + return { + activeSpace, + title, + }; +} + +export interface RoomListHeaderViewState { + /** + * The title of the room list + */ + title: string; + /** + * Whether to display the compose menu + * True if the user can create rooms + */ + displayComposeMenu: boolean; + /** + * Whether to display the space menu + * True if there is an active space + */ + displaySpaceMenu: boolean; + /** + * Whether the user can create rooms + */ + canCreateRoom: boolean; + /** + * Whether the user can create video rooms + */ + canCreateVideoRoom: boolean; + /** + * Whether the user can invite in the active space + */ + canInviteInSpace: boolean; + /** + * Whether the user can access space settings + */ + canAccessSpaceSettings: boolean; + /** + * Create a chat room + * @param e - The click event + */ + createChatRoom: (e: Event) => void; + /** + * Create a room + * @param e - The click event + */ + createRoom: (e: Event) => void; + /** + * Create a video room + */ + createVideoRoom: () => void; + /** + * Open the active space home + */ + openSpaceHome: () => void; + /** + * Display the space invite dialog + */ + inviteInSpace: () => void; + /** + * Open the space preferences + */ + openSpacePreferences: () => void; + /** + * Open the space settings + */ + openSpaceSettings: () => void; + /** + * Change the sort order of the room-list. + */ + sort: (option: SortOption) => void; + /** + * The currently active sort option. + */ + activeSortOption: SortOption; + /** + * Whether message previews must be shown or not. + */ + shouldShowMessagePreview: boolean; + /** + * A function to turn on/off message previews. + */ + toggleMessagePreview: () => void; +} + +/** + * View model for the RoomListHeader. + */ +export function useRoomListHeaderViewModel(): RoomListHeaderViewState { + const matrixClient = useMatrixClientContext(); + const { activeSpace, title } = useSpace(); + const isSpaceRoom = Boolean(activeSpace); + + const canCreateRoom = hasCreateRoomRights(matrixClient, activeSpace); + const canCreateVideoRoom = useFeatureEnabled("feature_video_rooms") && canCreateRoom; + const displayComposeMenu = canCreateRoom; + const displaySpaceMenu = isSpaceRoom; + const canInviteInSpace = Boolean( + activeSpace?.getJoinRule() === JoinRule.Public || activeSpace?.canInvite(matrixClient.getSafeUserId()), + ); + const canAccessSpaceSettings = Boolean(activeSpace && shouldShowSpaceSettings(activeSpace)); + + /* Actions */ + + const { activeSortOption, sort } = useSorter(); + const { shouldShowMessagePreview, toggleMessagePreview } = useMessagePreviewToggle(); + + const createChatRoom = useCallback((e: Event) => { + defaultDispatcher.fire(Action.CreateChat); + PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateChatItem", e); + }, []); + + const createRoomMemoized = useCallback( + (e: Event) => { + createRoom(activeSpace); + PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e); + }, + [activeSpace], + ); + + const elementCallVideoRoomsEnabled = useFeatureEnabled("feature_element_call_video_rooms"); + const createVideoRoom = useCallback(() => { + const type = elementCallVideoRoomsEnabled ? RoomType.UnstableCall : RoomType.ElementVideo; + if (activeSpace) { + showCreateNewRoom(activeSpace, type); + } else { + defaultDispatcher.dispatch({ + action: Action.CreateRoom, + type, + }); + } + }, [activeSpace, elementCallVideoRoomsEnabled]); + + const openSpaceHome = useCallback(() => { + // openSpaceHome is only available when there is an active space + if (!activeSpace) return; + defaultDispatcher.dispatch({ + action: Action.ViewRoom, + room_id: activeSpace.roomId, + metricsTrigger: undefined, + }); + }, [activeSpace]); + + const inviteInSpace = useCallback(() => { + // inviteInSpace is only available when there is an active space + if (!activeSpace) return; + showSpaceInvite(activeSpace); + }, [activeSpace]); + + const openSpacePreferences = useCallback(() => { + // openSpacePreferences is only available when there is an active space + if (!activeSpace) return; + showSpacePreferences(activeSpace); + }, [activeSpace]); + + const openSpaceSettings = useCallback(() => { + // openSpaceSettings is only available when there is an active space + if (!activeSpace) return; + showSpaceSettings(activeSpace); + }, [activeSpace]); + + return { + title, + displayComposeMenu, + displaySpaceMenu, + canCreateRoom, + canCreateVideoRoom, + canInviteInSpace, + canAccessSpaceSettings, + createChatRoom, + createRoom: createRoomMemoized, + createVideoRoom, + openSpaceHome, + inviteInSpace, + openSpacePreferences, + openSpaceSettings, + activeSortOption, + sort, + shouldShowMessagePreview, + toggleMessagePreview, + }; +} diff --git a/src/components/viewmodels/roomlist/RoomListItemMenuViewModel.tsx b/src/components/viewmodels/roomlist/RoomListItemMenuViewModel.tsx new file mode 100644 index 0000000000..997b515f27 --- /dev/null +++ b/src/components/viewmodels/roomlist/RoomListItemMenuViewModel.tsx @@ -0,0 +1,220 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { useCallback } from "react"; +import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix"; + +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; +import { useEventEmitterState } from "../../../hooks/useEventEmitter"; +import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications"; +import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils"; +import DMRoomMap from "../../../utils/DMRoomMap"; +import { DefaultTagID } from "../../../stores/room-list/models"; +import { NotificationLevel } from "../../../stores/notifications/NotificationLevel"; +import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; +import { UIComponent } from "../../../settings/UIFeature"; +import dispatcher from "../../../dispatcher/dispatcher"; +import { clearRoomNotification, setMarkedUnreadState } from "../../../utils/notifications"; +import PosthogTrackers from "../../../PosthogTrackers"; +import { tagRoom } from "../../../utils/room/tagRoom"; +import { RoomNotifState } from "../../../RoomNotifs"; +import { useNotificationState } from "../../../hooks/useRoomNotificationState"; + +export interface RoomListItemMenuViewState { + /** + * Whether the more options menu should be shown. + */ + showMoreOptionsMenu: boolean; + /** + * Whether the notification menu should be shown. + */ + showNotificationMenu: boolean; + /** + * Whether the room is a favourite room. + */ + isFavourite: boolean; + /** + * Can invite other user's in the room. + */ + canInvite: boolean; + /** + * Can copy the room link. + */ + canCopyRoomLink: boolean; + /** + * Can mark the room as read. + */ + canMarkAsRead: boolean; + /** + * Can mark the room as unread. + */ + canMarkAsUnread: boolean; + /** + * Whether the notification is set to all messages. + */ + isNotificationAllMessage: boolean; + /** + * Whether the notification is set to all messages loud. + */ + isNotificationAllMessageLoud: boolean; + /** + * Whether the notification is set to mentions and keywords only. + */ + isNotificationMentionOnly: boolean; + /** + * Whether the notification is muted. + */ + isNotificationMute: boolean; + /** + * Mark the room as read. + * @param evt + */ + markAsRead: (evt: Event) => void; + /** + * Mark the room as unread. + * @param evt + */ + markAsUnread: (evt: Event) => void; + /** + * Toggle the room as favourite. + * @param evt + */ + toggleFavorite: (evt: Event) => void; + /** + * Toggle the room as low priority. + */ + toggleLowPriority: () => void; + /** + * Invite other users in the room. + * @param evt + */ + invite: (evt: Event) => void; + /** + * Copy the room link in the clipboard. + * @param evt + */ + copyRoomLink: (evt: Event) => void; + /** + * Leave the room. + * @param evt + */ + leaveRoom: (evt: Event) => void; + /** + * Set the room notification state. + * @param state + */ + setRoomNotifState: (state: RoomNotifState) => void; +} + +export function useRoomListItemMenuViewModel(room: Room): RoomListItemMenuViewState { + const matrixClient = useMatrixClientContext(); + const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags); + const { level: notificationLevel } = useUnreadNotifications(room); + + const isDm = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId)); + const isFavourite = Boolean(roomTags[DefaultTagID.Favourite]); + const isArchived = Boolean(roomTags[DefaultTagID.Archived]); + + const showMoreOptionsMenu = hasAccessToOptionsMenu(room); + const showNotificationMenu = hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived); + + const canMarkAsRead = notificationLevel > NotificationLevel.None; + const canMarkAsUnread = !canMarkAsRead && !isArchived; + + const canInvite = + room.canInvite(matrixClient.getUserId()!) && !isDm && shouldShowComponent(UIComponent.InviteUsers); + const canCopyRoomLink = !isDm; + + const [roomNotifState, setRoomNotifState] = useNotificationState(room); + const isNotificationAllMessage = roomNotifState === RoomNotifState.AllMessages; + const isNotificationAllMessageLoud = roomNotifState === RoomNotifState.AllMessagesLoud; + const isNotificationMentionOnly = roomNotifState === RoomNotifState.MentionsOnly; + const isNotificationMute = roomNotifState === RoomNotifState.Mute; + + // Actions + + const markAsRead = useCallback( + async (evt: Event): Promise => { + await clearRoomNotification(room, matrixClient); + PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkRead", evt); + }, + [room, matrixClient], + ); + + const markAsUnread = useCallback( + async (evt: Event): Promise => { + await setMarkedUnreadState(room, matrixClient, true); + PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuMarkUnread", evt); + }, + [room, matrixClient], + ); + + const toggleFavorite = useCallback( + (evt: Event): void => { + tagRoom(room, DefaultTagID.Favourite); + PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle", evt); + }, + [room], + ); + + const toggleLowPriority = useCallback((): void => tagRoom(room, DefaultTagID.LowPriority), [room]); + + const invite = useCallback( + (evt: Event): void => { + dispatcher.dispatch({ + action: "view_invite", + roomId: room.roomId, + }); + PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuInviteItem", evt); + }, + [room], + ); + + const copyRoomLink = useCallback( + (evt: Event): void => { + dispatcher.dispatch({ + action: "copy_room", + room_id: room.roomId, + }); + PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuFavouriteToggle", evt); + }, + [room], + ); + + const leaveRoom = useCallback( + (evt: Event): void => { + dispatcher.dispatch({ + action: isArchived ? "forget_room" : "leave_room", + room_id: room.roomId, + }); + PosthogTrackers.trackInteraction("WebRoomListRoomTileContextMenuLeaveItem", evt); + }, + [room, isArchived], + ); + + return { + showMoreOptionsMenu, + showNotificationMenu, + isFavourite, + canInvite, + canCopyRoomLink, + canMarkAsRead, + canMarkAsUnread, + isNotificationAllMessage, + isNotificationAllMessageLoud, + isNotificationMentionOnly, + isNotificationMute, + markAsRead, + markAsUnread, + toggleFavorite, + toggleLowPriority, + invite, + copyRoomLink, + leaveRoom, + setRoomNotifState, + }; +} diff --git a/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx b/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx new file mode 100644 index 0000000000..e009033875 --- /dev/null +++ b/src/components/viewmodels/roomlist/RoomListItemViewModel.tsx @@ -0,0 +1,236 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix"; + +import dispatcher from "../../../dispatcher/dispatcher"; +import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { Action } from "../../../dispatcher/actions"; +import { hasAccessToNotificationMenu, hasAccessToOptionsMenu } from "./utils"; +import { _t } from "../../../languageHandler"; +import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState"; +import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; +import { useEventEmitter, useEventEmitterState, useTypedEventEmitter } from "../../../hooks/useEventEmitter"; +import { DefaultTagID } from "../../../stores/room-list/models"; +import { useCall, useConnectionState, useParticipantCount } from "../../../hooks/useCall"; +import { type ConnectionState } from "../../../models/Call"; +import { NotificationStateEvents } from "../../../stores/notifications/NotificationState"; +import DMRoomMap from "../../../utils/DMRoomMap"; +import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore"; +import { useMessagePreviewToggle } from "./useMessagePreviewToggle"; + +export interface RoomListItemViewState { + /** + * The name of the room. + */ + name: string; + /** + * Whether the hover menu should be shown. + */ + showHoverMenu: boolean; + /** + * Open the room having given roomId. + */ + openRoom: () => void; + /** + * The a11y label for the room list item. + */ + a11yLabel: string; + /** + * The notification state of the room. + */ + notificationState: RoomNotificationState; + /** + * Whether the room should be bolded. + */ + isBold: boolean; + /** + * Whether the room is a video room + */ + isVideoRoom: boolean; + /** + * The connection state of the call. + * `null` if there is no call in the room. + */ + callConnectionState: ConnectionState | null; + /** + * Whether there are participants in the call. + */ + hasParticipantInCall: boolean; + /** + * Pre-rendered and translated preview for the latest message in the room, or undefined + * if no preview should be shown. + */ + messagePreview: string | undefined; + /** + * Whether the notification decoration should be shown. + */ + showNotificationDecoration: boolean; +} + +/** + * View model for the room list item + * @see {@link RoomListItemViewState} for more information about what this view model returns. + */ +export function useRoomListItemViewModel(room: Room): RoomListItemViewState { + const matrixClient = useMatrixClientContext(); + const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags); + const isArchived = Boolean(roomTags[DefaultTagID.Archived]); + const name = useEventEmitterState(room, RoomEvent.Name, () => room.name); + + const notificationState = useMemo(() => RoomNotificationStateStore.instance.getRoomState(room), [room]); + + const [a11yLabel, setA11yLabel] = useState(getA11yLabel(name, notificationState)); + const [{ isBold, invited, hasVisibleNotification }, setNotificationValues] = useState( + getNotificationValues(notificationState), + ); + useEffect(() => { + setA11yLabel(getA11yLabel(name, notificationState)); + }, [name, notificationState]); + + // Listen to changes in the notification state and update the values + useTypedEventEmitter(notificationState, NotificationStateEvents.Update, () => { + setA11yLabel(getA11yLabel(name, notificationState)); + setNotificationValues(getNotificationValues(notificationState)); + }); + + // If the notification reference change due to room change, update the values + useEffect(() => { + setNotificationValues(getNotificationValues(notificationState)); + }, [notificationState]); + + // We don't want to show the hover menu if + // - there is an invitation for this room + // - the user doesn't have access to both notification and more options menus + const showHoverMenu = + !invited && + (hasAccessToOptionsMenu(room) || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived)); + + const messagePreview = useRoomMessagePreview(room); + + // Video room + const isVideoRoom = room.isElementVideoRoom() || room.isCallRoom(); + // EC video call or video room + const call = useCall(room.roomId); + const connectionState = useConnectionState(call); + const hasParticipantInCall = useParticipantCount(call) > 0; + const callConnectionState = call ? connectionState : null; + + const showNotificationDecoration = hasVisibleNotification || hasParticipantInCall; + + // Actions + + const openRoom = useCallback((): void => { + dispatcher.dispatch({ + action: Action.ViewRoom, + room_id: room.roomId, + metricsTrigger: "RoomList", + }); + }, [room]); + + return { + name, + notificationState, + showHoverMenu, + openRoom, + a11yLabel, + isBold, + isVideoRoom, + callConnectionState, + hasParticipantInCall, + messagePreview, + showNotificationDecoration, + }; +} + +/** + * Calculate the values from the notification state + * @param notificationState + */ +function getNotificationValues(notificationState: RoomNotificationState): { + computeA11yLabel: (name: string) => string; + isBold: boolean; + invited: boolean; + hasVisibleNotification: boolean; +} { + const invited = notificationState.invited; + const computeA11yLabel = (name: string): string => getA11yLabel(name, notificationState); + const isBold = notificationState.hasAnyNotificationOrActivity; + + const hasVisibleNotification = notificationState.hasAnyNotificationOrActivity || notificationState.muted; + + return { + computeA11yLabel, + isBold, + invited, + hasVisibleNotification, + }; +} + +/** + * Get the a11y label for the room list item + * @param roomName + * @param notificationState + */ +function getA11yLabel(roomName: string, notificationState: RoomNotificationState): string { + if (notificationState.isUnsentMessage) { + return _t("a11y|room_messsage_not_sent", { + roomName, + }); + } else if (notificationState.invited) { + return _t("a11y|room_n_unread_invite", { + roomName, + }); + } else if (notificationState.isMention) { + return _t("a11y|room_n_unread_messages_mentions", { + roomName, + count: notificationState.count, + }); + } else if (notificationState.hasUnreadCount) { + return _t("a11y|room_n_unread_messages", { + roomName, + count: notificationState.count, + }); + } else { + return _t("room_list|room|open_room", { roomName }); + } +} + +function useRoomMessagePreview(room: Room): string | undefined { + const { shouldShowMessagePreview } = useMessagePreviewToggle(); + const [previewText, setPreviewText] = useState(undefined); + + const updatePreview = useCallback(async () => { + if (!shouldShowMessagePreview) { + setPreviewText(undefined); + return; + } + + const roomIsDM = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId)); + // For the tag, we only care about whether the room is a DM or not as we don't show + // display names in previewsd for DMs, so anything else we just say is 'untagged' + // (even though it could actually be have other tags: we don't care about them). + const messagePreview = await MessagePreviewStore.instance.getPreviewForRoom( + room, + roomIsDM ? DefaultTagID.DM : DefaultTagID.Untagged, + ); + setPreviewText(messagePreview?.text); + }, [room, shouldShowMessagePreview]); + + // MessagePreviewStore and the other AsyncStores need to be converted to TypedEventEmitter + useEventEmitter(MessagePreviewStore.instance, MessagePreviewStore.getPreviewChangedEventName(room), () => { + updatePreview(); + }); + + useEffect(() => { + updatePreview(); + }, [updatePreview]); + + return previewText; +} diff --git a/src/components/viewmodels/roomlist/RoomListViewModel.tsx b/src/components/viewmodels/roomlist/RoomListViewModel.tsx new file mode 100644 index 0000000000..e4e5093782 --- /dev/null +++ b/src/components/viewmodels/roomlist/RoomListViewModel.tsx @@ -0,0 +1,99 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { useCallback } from "react"; + +import type { Room } from "matrix-js-sdk/src/matrix"; +import { type PrimaryFilter, useFilteredRooms } from "./useFilteredRooms"; +import { createRoom as createRoomFunc, hasCreateRoomRights } from "./utils"; +import { useEventEmitterState } from "../../../hooks/useEventEmitter"; +import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces"; +import SpaceStore from "../../../stores/spaces/SpaceStore"; +import dispatcher from "../../../dispatcher/dispatcher"; +import { Action } from "../../../dispatcher/actions"; +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; +import { useStickyRoomList } from "./useStickyRoomList"; +import { useRoomListNavigation } from "./useRoomListNavigation"; + +export interface RoomListViewState { + /** + * Whether the list of rooms is being loaded. + */ + isLoadingRooms: boolean; + + /** + * A list of rooms to be displayed in the left panel. + */ + rooms: Room[]; + + /** + * Create a chat room + * @param e - The click event + */ + createChatRoom: () => void; + + /** + * Whether the user can create a room in the current space + */ + canCreateRoom: boolean; + + /** + * Create a room + * @param e - The click event + */ + createRoom: () => void; + + /** + * A list of objects that provide the view enough information + * to render primary room filters. + */ + primaryFilters: PrimaryFilter[]; + + /** + * The currently active primary filter. + * If no primary filter is active, this will be undefined. + */ + activePrimaryFilter?: PrimaryFilter; + + /** + * The index of the active room in the room list. + */ + activeIndex: number | undefined; +} + +/** + * View model for the new room list + * @see {@link RoomListViewState} for more information about what this view model returns. + */ +export function useRoomListViewModel(): RoomListViewState { + const matrixClient = useMatrixClientContext(); + const { isLoadingRooms, primaryFilters, activePrimaryFilter, rooms: filteredRooms } = useFilteredRooms(); + const { activeIndex, rooms } = useStickyRoomList(filteredRooms); + + useRoomListNavigation(rooms); + + const currentSpace = useEventEmitterState( + SpaceStore.instance, + UPDATE_SELECTED_SPACE, + () => SpaceStore.instance.activeSpaceRoom, + ); + const canCreateRoom = hasCreateRoomRights(matrixClient, currentSpace); + + const createChatRoom = useCallback(() => dispatcher.fire(Action.CreateChat), []); + const createRoom = useCallback(() => createRoomFunc(currentSpace), [currentSpace]); + + return { + isLoadingRooms, + rooms, + canCreateRoom, + createRoom, + createChatRoom, + primaryFilters, + activePrimaryFilter, + activeIndex, + }; +} diff --git a/src/components/viewmodels/roomlist/useFilteredRooms.tsx b/src/components/viewmodels/roomlist/useFilteredRooms.tsx new file mode 100644 index 0000000000..c3ed4df2ae --- /dev/null +++ b/src/components/viewmodels/roomlist/useFilteredRooms.tsx @@ -0,0 +1,126 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { useCallback, useMemo, useState } from "react"; + +import type { Room } from "matrix-js-sdk/src/matrix"; +import { FilterKey } from "../../../stores/room-list-v3/skip-list/filters"; +import { _t, _td, type TranslationKey } from "../../../languageHandler"; +import RoomListStoreV3, { LISTS_LOADED_EVENT, LISTS_UPDATE_EVENT } from "../../../stores/room-list-v3/RoomListStoreV3"; +import { useEventEmitter } from "../../../hooks/useEventEmitter"; +import SpaceStore from "../../../stores/spaces/SpaceStore"; +import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces"; + +/** + * Provides information about a primary filter. + * A primary filter is a commonly used filter that is given + * more precedence in the UI. For eg, primary filters may be + * rendered as pills above the room list. + */ +export interface PrimaryFilter { + // A function to toggle this filter on and off. + toggle: () => void; + // Whether this filter is currently applied + active: boolean; + // Text that can be used in the UI to represent this filter. + name: string; + // The key of the filter + key: FilterKey; +} + +interface FilteredRooms { + primaryFilters: PrimaryFilter[]; + isLoadingRooms: boolean; + rooms: Room[]; + /** + * The currently active primary filter. + * If no primary filter is active, this will be undefined. + */ + activePrimaryFilter?: PrimaryFilter; +} + +const filterKeyToNameMap: Map = new Map([ + [FilterKey.UnreadFilter, _td("room_list|filters|unread")], + [FilterKey.PeopleFilter, _td("room_list|filters|people")], + [FilterKey.RoomsFilter, _td("room_list|filters|rooms")], + [FilterKey.MentionsFilter, _td("room_list|filters|mentions")], + [FilterKey.InvitesFilter, _td("room_list|filters|invites")], + [FilterKey.FavouriteFilter, _td("room_list|filters|favourite")], +]); + +/** + * Track available filters and provide a filtered list of rooms. + */ +export function useFilteredRooms(): FilteredRooms { + /** + * Primary filter refers to the pill based filters + * rendered above the room list. + */ + const [primaryFilter, setPrimaryFilter] = useState(); + + const [rooms, setRooms] = useState(() => RoomListStoreV3.instance.getSortedRoomsInActiveSpace()); + const [isLoadingRooms, setIsLoadingRooms] = useState(() => RoomListStoreV3.instance.isLoadingRooms); + + const updateRoomsFromStore = useCallback((filters: FilterKey[] = []): void => { + const newRooms = RoomListStoreV3.instance.getSortedRoomsInActiveSpace(filters); + setRooms(newRooms); + }, []); + + // Reset filters when active space changes + useEventEmitter(SpaceStore.instance, UPDATE_SELECTED_SPACE, () => setPrimaryFilter(undefined)); + + const filterUndefined = (array: (FilterKey | undefined)[]): FilterKey[] => + array.filter((f) => f !== undefined) as FilterKey[]; + + const getAppliedFilters = (): FilterKey[] => { + return filterUndefined([primaryFilter]); + }; + + useEventEmitter(RoomListStoreV3.instance, LISTS_UPDATE_EVENT, () => { + const filters = getAppliedFilters(); + updateRoomsFromStore(filters); + }); + + useEventEmitter(RoomListStoreV3.instance, LISTS_LOADED_EVENT, () => { + setIsLoadingRooms(false); + }); + + /** + * This tells the view which primary filters are available, how to toggle them + * and whether a given primary filter is active. @see {@link PrimaryFilter} + */ + const primaryFilters = useMemo(() => { + const createPrimaryFilter = (key: FilterKey, name: string): PrimaryFilter => { + return { + toggle: () => { + setPrimaryFilter((currentFilter) => { + const filter = currentFilter === key ? undefined : key; + updateRoomsFromStore(filterUndefined([filter])); + return filter; + }); + }, + active: primaryFilter === key, + name, + key, + }; + }; + const filters: PrimaryFilter[] = []; + for (const [key, name] of filterKeyToNameMap.entries()) { + filters.push(createPrimaryFilter(key, _t(name))); + } + return filters; + }, [primaryFilter, updateRoomsFromStore]); + + const activePrimaryFilter = useMemo(() => primaryFilters.find((filter) => filter.active), [primaryFilters]); + + return { + isLoadingRooms, + primaryFilters, + activePrimaryFilter, + rooms, + }; +} diff --git a/src/components/viewmodels/roomlist/useMessagePreviewToggle.tsx b/src/components/viewmodels/roomlist/useMessagePreviewToggle.tsx new file mode 100644 index 0000000000..efb58b3e04 --- /dev/null +++ b/src/components/viewmodels/roomlist/useMessagePreviewToggle.tsx @@ -0,0 +1,32 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ +import { useCallback } from "react"; + +import SettingsStore from "../../../settings/SettingsStore"; +import { SettingLevel } from "../../../settings/SettingLevel"; +import { useSettingValue } from "../../../hooks/useSettings"; + +interface MessagePreviewToggleState { + shouldShowMessagePreview: boolean; + toggleMessagePreview: () => void; +} + +/** + * This hook: + * - Provides a state that tracks whether message previews are turned on or off. + * - Provides a function to toggle message previews. + */ +export function useMessagePreviewToggle(): MessagePreviewToggleState { + const shouldShowMessagePreview = useSettingValue("RoomList.showMessagePreview"); + + const toggleMessagePreview = useCallback((): void => { + const toggled = !shouldShowMessagePreview; + SettingsStore.setValue("RoomList.showMessagePreview", null, SettingLevel.DEVICE, toggled); + }, [shouldShowMessagePreview]); + + return { toggleMessagePreview, shouldShowMessagePreview }; +} diff --git a/src/components/viewmodels/roomlist/useRoomListNavigation.ts b/src/components/viewmodels/roomlist/useRoomListNavigation.ts new file mode 100644 index 0000000000..5ef979e79c --- /dev/null +++ b/src/components/viewmodels/roomlist/useRoomListNavigation.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type Room } from "matrix-js-sdk/src/matrix"; + +import dispatcher from "../../../dispatcher/dispatcher"; +import { useDispatcher } from "../../../hooks/useDispatcher"; +import { Action } from "../../../dispatcher/actions"; +import { type ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload"; +import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { SdkContextClass } from "../../../contexts/SDKContext"; +import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; + +/** + * Hook to navigate the room list using keyboard shortcuts. + * It listens to the ViewRoomDelta action and updates the room list accordingly. + * @param rooms + */ +export function useRoomListNavigation(rooms: Room[]): void { + useDispatcher(dispatcher, (payload) => { + if (payload.action !== Action.ViewRoomDelta) return; + const roomId = SdkContextClass.instance.roomViewStore.getRoomId(); + if (!roomId) return; + + const { delta, unread } = payload as ViewRoomDeltaPayload; + const filteredRooms = unread + ? // Filter the rooms to only include unread ones and the active room + rooms.filter((room) => { + const state = RoomNotificationStateStore.instance.getRoomState(room); + return room.roomId === roomId || state.isUnread; + }) + : rooms; + + const currentIndex = filteredRooms.findIndex((room) => room.roomId === roomId); + if (currentIndex === -1) return; + + // Get the next/previous new room according to the delta + // Use slice to loop on the list + // If delta is -1 at the start of the list, it will go to the end + // If delta is 1 at the end of the list, it will go to the start + const [newRoom] = filteredRooms.slice((currentIndex + delta) % filteredRooms.length); + if (!newRoom) return; + + dispatcher.dispatch({ + action: Action.ViewRoom, + room_id: newRoom.roomId, + show_room_tile: true, // to make sure the room gets scrolled into view + metricsTrigger: "WebKeyboardShortcut", + metricsViaKeyboard: true, + }); + }); +} diff --git a/src/components/viewmodels/roomlist/useSorter.ts b/src/components/viewmodels/roomlist/useSorter.ts new file mode 100644 index 0000000000..c7a880d430 --- /dev/null +++ b/src/components/viewmodels/roomlist/useSorter.ts @@ -0,0 +1,62 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ +import { useState } from "react"; + +import RoomListStoreV3 from "../../../stores/room-list-v3/RoomListStoreV3"; +import { SortingAlgorithm } from "../../../stores/room-list-v3/skip-list/sorters"; +import SettingsStore from "../../../settings/SettingsStore"; + +/** + * Sorting options made available to the view. + */ +export const enum SortOption { + Activity = SortingAlgorithm.Recency, + AToZ = SortingAlgorithm.Alphabetic, +} + +/** + * {@link SortOption} holds almost the same information as + * {@link SortingAlgorithm}. This is done intentionally to + * prevent the view from having a dependence on the + * model (which is the store in this case). + */ +const sortingAlgorithmToSortingOption = { + [SortingAlgorithm.Alphabetic]: SortOption.AToZ, + [SortingAlgorithm.Recency]: SortOption.Activity, +}; + +const sortOptionToSortingAlgorithm = { + [SortOption.AToZ]: SortingAlgorithm.Alphabetic, + [SortOption.Activity]: SortingAlgorithm.Recency, +}; + +interface SortState { + sort: (option: SortOption) => void; + activeSortOption: SortOption; +} + +/** + * This hook does two things: + * - Provides a way to track the currently active sort option. + * - Provides a function to resort the room list. + */ +export function useSorter(): SortState { + const [activeSortingAlgorithm, setActiveSortingAlgorithm] = useState(() => + SettingsStore.getValue("RoomList.preferredSorting"), + ); + + const sort = (option: SortOption): void => { + const sortingAlgorithm = sortOptionToSortingAlgorithm[option]; + RoomListStoreV3.instance.resort(sortingAlgorithm); + setActiveSortingAlgorithm(sortingAlgorithm); + }; + + return { + sort, + activeSortOption: sortingAlgorithmToSortingOption[activeSortingAlgorithm!], + }; +} diff --git a/src/components/viewmodels/roomlist/useStickyRoomList.tsx b/src/components/viewmodels/roomlist/useStickyRoomList.tsx new file mode 100644 index 0000000000..06feb58581 --- /dev/null +++ b/src/components/viewmodels/roomlist/useStickyRoomList.tsx @@ -0,0 +1,134 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { SdkContextClass } from "../../../contexts/SDKContext"; +import { useDispatcher } from "../../../hooks/useDispatcher"; +import dispatcher from "../../../dispatcher/dispatcher"; +import { Action } from "../../../dispatcher/actions"; +import type { Room } from "matrix-js-sdk/src/matrix"; +import type { Optional } from "matrix-events-sdk"; +import SpaceStore from "../../../stores/spaces/SpaceStore"; + +function getIndexByRoomId(rooms: Room[], roomId: Optional): number | undefined { + const index = rooms.findIndex((room) => room.roomId === roomId); + return index === -1 ? undefined : index; +} + +function getRoomsWithStickyRoom( + rooms: Room[], + oldIndex: number | undefined, + newIndex: number | undefined, + isRoomChange: boolean, +): { newRooms: Room[]; newIndex: number | undefined } { + const updated = { newIndex, newRooms: rooms }; + if (isRoomChange) { + /* + * When opening another room, the index should obviously change. + */ + return updated; + } + if (newIndex === undefined || oldIndex === undefined) { + /* + * If oldIndex is undefined, then there was no active room before. + * So nothing to do in regards to sticky room. + * Similarly, if newIndex is undefined, there's no active room anymore. + */ + return updated; + } + if (newIndex === oldIndex) { + /* + * If the index hasn't changed, we have nothing to do. + */ + return updated; + } + if (oldIndex > rooms.length - 1) { + /* + * If the old index falls out of the bounds of the rooms array + * (usually because rooms were removed), we can no longer place + * the active room in the same old index. + */ + return updated; + } + + /* + * Making the active room sticky is as simple as removing it from + * its new index and placing it in the old index. + */ + const newRooms = [...rooms]; + const [newRoom] = newRooms.splice(newIndex, 1); + newRooms.splice(oldIndex, 0, newRoom); + + return { newIndex: oldIndex, newRooms }; +} + +interface StickyRoomListResult { + /** + * List of rooms with sticky active room. + */ + rooms: Room[]; + /** + * Index of the active room in the room list. + */ + activeIndex: number | undefined; +} + +/** + * - Provides a list of rooms such that the active room is sticky i.e the active room is kept + * in the same index even when the order of rooms in the list changes. + * - Provides the index of the active room. + * @param rooms list of rooms + * @see {@link StickyRoomListResult} details what this hook returns.. + */ +export function useStickyRoomList(rooms: Room[]): StickyRoomListResult { + const [listState, setListState] = useState<{ index: number | undefined; roomsWithStickyRoom: Room[] }>({ + index: undefined, + roomsWithStickyRoom: rooms, + }); + + const currentSpaceRef = useRef(SpaceStore.instance.activeSpace); + + const updateRoomsAndIndex = useCallback( + (newRoomId: string | null, isRoomChange: boolean = false) => { + setListState((current) => { + const activeRoomId = newRoomId ?? SdkContextClass.instance.roomViewStore.getRoomId(); + const newActiveIndex = getIndexByRoomId(rooms, activeRoomId); + const oldIndex = current.index; + const { newIndex, newRooms } = getRoomsWithStickyRoom(rooms, oldIndex, newActiveIndex, isRoomChange); + return { index: newIndex, roomsWithStickyRoom: newRooms }; + }); + }, + [rooms], + ); + + // Re-calculate the index when the active room has changed. + useDispatcher(dispatcher, (payload) => { + if (payload.action === Action.ActiveRoomChanged) updateRoomsAndIndex(payload.newRoomId, true); + }); + + // Re-calculate the index when the list of rooms has changed. + useEffect(() => { + let newRoomId: string | null = null; + let isRoomChange = false; + const newSpace = SpaceStore.instance.activeSpace; + if (currentSpaceRef.current !== newSpace) { + /* + If the space has changed, we check if we can immediately set the active + index to the last opened room in that space. Otherwise, we might see a + flicker because of the delay between the space change event and + active room change dispatch. + */ + newRoomId = SpaceStore.instance.getLastSelectedRoomIdForSpace(newSpace); + isRoomChange = true; + currentSpaceRef.current = newSpace; + } + updateRoomsAndIndex(newRoomId, isRoomChange); + }, [rooms, updateRoomsAndIndex]); + + return { activeIndex: listState.index, rooms: listState.roomsWithStickyRoom }; +} diff --git a/src/components/viewmodels/roomlist/utils.ts b/src/components/viewmodels/roomlist/utils.ts new file mode 100644 index 0000000000..dfa20e0d1c --- /dev/null +++ b/src/components/viewmodels/roomlist/utils.ts @@ -0,0 +1,68 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import { type Room, KnownMembership, EventTimeline, EventType, type MatrixClient } from "matrix-js-sdk/src/matrix"; + +import { isKnockDenied } from "../../../utils/membership"; +import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; +import { UIComponent } from "../../../settings/UIFeature"; +import { showCreateNewRoom } from "../../../utils/space"; +import dispatcher from "../../../dispatcher/dispatcher"; +import { Action } from "../../../dispatcher/actions"; + +/** + * Check if the user has access to the options menu. + * @param room + */ +export function hasAccessToOptionsMenu(room: Room): boolean { + return ( + room.getMyMembership() === KnownMembership.Invite || + (room.getMyMembership() !== KnownMembership.Knock && + !isKnockDenied(room) && + shouldShowComponent(UIComponent.RoomOptionsMenu)) + ); +} + +/** + * Check if the user has access to the notification menu. + * @param room + * @param isGuest + * @param isArchived + */ +export function hasAccessToNotificationMenu(room: Room, isGuest: boolean, isArchived: boolean): boolean { + return !isGuest && !isArchived && hasAccessToOptionsMenu(room); +} + +/** + * Create a room + * @param space - The space to create the room in + */ +export async function createRoom(space?: Room | null): Promise { + if (space) { + await showCreateNewRoom(space); + } else { + dispatcher.fire(Action.CreateRoom); + } +} + +/** + * Check if the user has the rights to create a room in the given space + * If the space is not provided, it will check if the user has the rights to create a room in general + * @param matrixClient + * @param space + */ +export function hasCreateRoomRights(matrixClient: MatrixClient, space?: Room | null): boolean { + const hasUIRight = shouldShowComponent(UIComponent.CreateRooms); + if (!space || !hasUIRight) return hasUIRight; + + return Boolean( + space + ?.getLiveTimeline() + .getState(EventTimeline.FORWARDS) + ?.maySendStateEvent(EventType.RoomAvatar, matrixClient.getSafeUserId()), + ); +} diff --git a/src/components/viewmodels/rooms/UserIdentityWarningViewModel.tsx b/src/components/viewmodels/rooms/UserIdentityWarningViewModel.tsx new file mode 100644 index 0000000000..9e9f0f7b3a --- /dev/null +++ b/src/components/viewmodels/rooms/UserIdentityWarningViewModel.tsx @@ -0,0 +1,192 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { EventType, type MatrixEvent, type Room, type RoomMember, RoomStateEvent } from "matrix-js-sdk/src/matrix"; +import { type CryptoApi, CryptoEvent } from "matrix-js-sdk/src/crypto-api"; +import { throttle } from "lodash"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { useMatrixClientContext } from "../../../contexts/MatrixClientContext.tsx"; +import { useTypedEventEmitter } from "../../../hooks/useEventEmitter.ts"; + +export type ViolationType = "PinViolation" | "VerificationViolation"; + +/** + * Represents a prompt to the user about a violation in the room. + * The type of violation and the member it relates to are included. + * If the type is "VerificationViolation", the warning is critical and should be reported with more urgency. + */ +export type ViolationPrompt = { + member: RoomMember; + type: ViolationType; +}; + +/** + * The state of the UserIdentityWarningViewModel. + * This includes the current prompt to show to the user and a callback to handle button clicks. + * If currentPrompt is undefined, there are no violations to show. + */ +export interface UserIdentityWarningState { + currentPrompt?: ViolationPrompt; + dispatchAction: (action: UserIdentityWarningViewModelAction) => void; +} + +/** + * List of actions that can be dispatched to the UserIdentityWarningViewModel. + */ +export type UserIdentityWarningViewModelAction = + | { type: "PinUserIdentity"; userId: string } + | { type: "WithdrawVerification"; userId: string }; + +/** + * Maps a list of room members to a list of violations. + * Checks for all members in the room to see if they have any violations. + * If no violations are found, an empty list is returned. + * + * @param cryptoApi + * @param members - The list of room members to check for violations. + */ +async function mapToViolations(cryptoApi: CryptoApi, members: RoomMember[]): Promise { + const violationList = new Array(); + for (const member of members) { + const verificationStatus = await cryptoApi.getUserVerificationStatus(member.userId); + if (verificationStatus.wasCrossSigningVerified() && !verificationStatus.isCrossSigningVerified()) { + violationList.push({ member, type: "VerificationViolation" }); + } else if (verificationStatus.needsUserApproval) { + violationList.push({ member, type: "PinViolation" }); + } + } + return violationList; +} + +export function useUserIdentityWarningViewModel(room: Room, key: string): UserIdentityWarningState { + const cli = useMatrixClientContext(); + const crypto = cli.getCrypto(); + + const [members, setMembers] = useState([]); + const [currentPrompt, setCurrentPrompt] = useState(undefined); + + const loadViolations = useMemo( + () => + throttle(async (): Promise => { + const isEncrypted = crypto && (await crypto.isEncryptionEnabledInRoom(room.roomId)); + if (!isEncrypted) { + setMembers([]); + setCurrentPrompt(undefined); + return; + } + + const targetMembers = await room.getEncryptionTargetMembers(); + setMembers(targetMembers); + const violations = await mapToViolations(crypto, targetMembers); + + let candidatePrompt: ViolationPrompt | undefined; + if (violations.length > 0) { + // sort by user ID to ensure consistent ordering + const sortedViolations = violations.sort((a, b) => a.member.userId.localeCompare(b.member.userId)); + candidatePrompt = sortedViolations[0]; + } else { + candidatePrompt = undefined; + } + + // is the current prompt still valid? + setCurrentPrompt((existingPrompt): ViolationPrompt | undefined => { + if (existingPrompt && violations.includes(existingPrompt)) { + return existingPrompt; + } else if (candidatePrompt) { + return candidatePrompt; + } else { + return undefined; + } + }); + }), + [crypto, room], + ); + + // We need to listen for changes to the members list + useTypedEventEmitter( + cli, + RoomStateEvent.Events, + useCallback( + async (event: MatrixEvent): Promise => { + if (!crypto || event.getRoomId() !== room.roomId) { + return; + } + let shouldRefresh = false; + + const eventType = event.getType(); + + if (eventType === EventType.RoomEncryption && event.getStateKey() === "") { + // Room is now encrypted, so we can initialise the component. + shouldRefresh = true; + } else if (eventType == EventType.RoomMember) { + // We're processing an m.room.member event + // Something has changed in membership, someone joined or someone left or + // someone changed their display name. Anyhow let's refresh. + const userId = event.getStateKey(); + shouldRefresh = !!userId; + } + + if (shouldRefresh) { + loadViolations().catch((e) => { + logger.error("Error refreshing UserIdentityWarningViewModel:", e); + }); + } + }, + [crypto, room, loadViolations], + ), + ); + + // We need to listen for changes to the verification status of the members to refresh violations + useTypedEventEmitter( + cli, + CryptoEvent.UserTrustStatusChanged, + useCallback( + (userId: string): void => { + if (members.find((m) => m.userId == userId)) { + // This member is tracked, we need to refresh. + // refresh all for now? + // As a later optimisation we could store the current violations and only update the relevant one. + loadViolations().catch((e) => { + logger.error("Error refreshing UserIdentityWarning:", e); + }); + } + }, + [loadViolations, members], + ), + ); + + useEffect(() => { + loadViolations().catch((e) => { + logger.error("Error initialising UserIdentityWarning:", e); + }); + }, [loadViolations]); + + const dispatchAction = useCallback( + (action: UserIdentityWarningViewModelAction): void => { + if (!crypto) { + return; + } + if (action.type === "PinUserIdentity") { + crypto.pinCurrentUserIdentity(action.userId).catch((e) => { + logger.error("Error pinning user identity:", e); + }); + } else if (action.type === "WithdrawVerification") { + crypto.withdrawVerificationRequirement(action.userId).catch((e) => { + logger.error("Error withdrawing verification requirement:", e); + }); + } + }, + [crypto], + ); + + return { + currentPrompt, + dispatchAction, + }; +} diff --git a/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts b/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts new file mode 100644 index 0000000000..ee301bd27f --- /dev/null +++ b/src/components/viewmodels/settings/encryption/KeyStoragePanelViewModel.ts @@ -0,0 +1,116 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import { useCallback, useEffect, useState } from "react"; +import { logger } from "matrix-js-sdk/src/logger"; + +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import DeviceListener, { BACKUP_DISABLED_ACCOUNT_DATA_KEY } from "../../../../DeviceListener"; + +interface KeyStoragePanelState { + /** + * Whether the app's "key storage" option should show as enabled to the user, + * or 'undefined' if the state is still loading. + */ + isEnabled: boolean | undefined; + + /** + * A function that can be called to enable or disable key storage. + * @param enable True to turn key storage on or false to turn it off + */ + setEnabled: (enable: boolean) => void; + + /** + * True if the state is still loading for the first time + */ + loading: boolean; + + /** + * True if the status is in the process of being changed + */ + busy: boolean; +} + +/** Returns a ViewModel for use in {@link KeyStoragePanel} and {@link DeleteKeyStoragePanel}. */ +export function useKeyStoragePanelViewModel(): KeyStoragePanelState { + const [isEnabled, setIsEnabled] = useState(undefined); + const [loading, setLoading] = useState(true); + // Whilst the change is being made, the toggle will reflect the pending value rather than the actual state + const [pendingValue, setPendingValue] = useState(undefined); + + const matrixClient = useMatrixClientContext(); + + const checkStatus = useCallback(async () => { + const crypto = matrixClient.getCrypto(); + if (!crypto) { + logger.error("Can't check key backup status: no crypto module available"); + return; + } + // The toggle is enabled only if this device will upload megolm keys to the backup. + // This is consistent with EX. + const activeBackupVersion = await crypto.getActiveSessionBackupVersion(); + setIsEnabled(activeBackupVersion !== null); + }, [matrixClient]); + + useEffect(() => { + (async () => { + await checkStatus(); + setLoading(false); + })(); + }, [checkStatus]); + + const setEnabled = useCallback( + async (enable: boolean) => { + setPendingValue(enable); + try { + // stop the device listener since enabling or (especially) disabling key storage must be + // done with a sequence of API calls that will put the account in a slightly different + // state each time, so suppress any warning toasts until the process is finished (when + // we'll turn it back on again.) + DeviceListener.sharedInstance().stop(); + + const crypto = matrixClient.getCrypto(); + if (!crypto) { + logger.error("Can't change key backup status: no crypto module available"); + return; + } + if (enable) { + // If there is no existing key backup on the server, create one. + // `resetKeyBackup` will delete any existing backup, so we only do this if there is no existing backup. + const currentKeyBackup = await crypto.checkKeyBackupAndEnable(); + if (currentKeyBackup === null) { + await crypto.resetKeyBackup(); + + // resetKeyBackup fires this off in the background without waiting, so we need to do it + // explicitly and wait for it, otherwise it won't be enabled yet when we check again. + await crypto.checkKeyBackupAndEnable(); + } + + // Set the flag so that EX no longer thinks the user wants backup disabled + await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: false }); + } else { + // This method will delete the key backup as well as server side recovery keys and other + // server-side crypto data. + await crypto.disableKeyStorage(); + + // Set a flag to say that the user doesn't want key backup. + // Element X uses this to determine whether to set up automatically, + // so this will stop EX turning it back on spontaneously. + await matrixClient.setAccountData(BACKUP_DISABLED_ACCOUNT_DATA_KEY, { disabled: true }); + } + + await checkStatus(); + } finally { + setPendingValue(undefined); + DeviceListener.sharedInstance().start(matrixClient); + } + }, + [setPendingValue, checkStatus, matrixClient], + ); + + return { isEnabled: pendingValue ?? isEnabled, setEnabled, loading, busy: pendingValue !== undefined }; +} diff --git a/src/components/views/audio_messages/AudioPlayer.tsx b/src/components/views/audio_messages/AudioPlayer.tsx index 63c77108e3..6f674e504d 100644 --- a/src/components/views/audio_messages/AudioPlayer.tsx +++ b/src/components/views/audio_messages/AudioPlayer.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ReactNode } from "react"; +import React, { type ReactNode } from "react"; import PlayPauseButton from "./PlayPauseButton"; import { formatBytes } from "../../../utils/FormattingUtils"; diff --git a/src/components/views/audio_messages/AudioPlayerBase.tsx b/src/components/views/audio_messages/AudioPlayerBase.tsx index 97cbad0fc2..71bea008a2 100644 --- a/src/components/views/audio_messages/AudioPlayerBase.tsx +++ b/src/components/views/audio_messages/AudioPlayerBase.tsx @@ -6,16 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { createRef, ReactNode, RefObject } from "react"; +import React, { createRef, type ReactNode } from "react"; import { logger } from "matrix-js-sdk/src/logger"; -import { Playback, PlaybackState } from "../../../audio/Playback"; +import { type Playback, type PlaybackState } from "../../../audio/Playback"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; import { _t } from "../../../languageHandler"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; -import SeekBar from "./SeekBar"; -import PlayPauseButton from "./PlayPauseButton"; +import type SeekBar from "./SeekBar"; +import type PlayPauseButton from "./PlayPauseButton"; export interface IProps { // Playback instance to render. Cannot change during component lifecycle: create @@ -31,8 +31,8 @@ interface IState { } export default abstract class AudioPlayerBase extends React.PureComponent { - protected seekRef: RefObject = createRef(); - protected playPauseRef: RefObject = createRef(); + protected seekRef = createRef(); + protected playPauseRef = createRef(); public constructor(props: T) { super(props); diff --git a/src/components/views/audio_messages/Clock.tsx b/src/components/views/audio_messages/Clock.tsx index c8f27c3f9c..d9f1a3d6c5 100644 --- a/src/components/views/audio_messages/Clock.tsx +++ b/src/components/views/audio_messages/Clock.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { HTMLProps } from "react"; +import React, { type HTMLProps } from "react"; import { Temporal } from "temporal-polyfill"; import { formatSeconds } from "../../../DateUtils"; diff --git a/src/components/views/audio_messages/DurationClock.tsx b/src/components/views/audio_messages/DurationClock.tsx index 1a84a15955..920baa99be 100644 --- a/src/components/views/audio_messages/DurationClock.tsx +++ b/src/components/views/audio_messages/DurationClock.tsx @@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import Clock from "./Clock"; -import { Playback } from "../../../audio/Playback"; +import { type Playback } from "../../../audio/Playback"; interface IProps { playback: Playback; diff --git a/src/components/views/audio_messages/LiveRecordingClock.tsx b/src/components/views/audio_messages/LiveRecordingClock.tsx index bd8b8d5d23..74415566a6 100644 --- a/src/components/views/audio_messages/LiveRecordingClock.tsx +++ b/src/components/views/audio_messages/LiveRecordingClock.tsx @@ -8,10 +8,10 @@ Please see LICENSE files in the repository root for full details. import React from "react"; -import { IRecordingUpdate } from "../../../audio/VoiceRecording"; +import { type IRecordingUpdate } from "../../../audio/VoiceRecording"; import Clock from "./Clock"; import { MarkedExecution } from "../../../utils/MarkedExecution"; -import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording"; +import { type VoiceMessageRecording } from "../../../audio/VoiceMessageRecording"; interface IProps { recorder: VoiceMessageRecording; diff --git a/src/components/views/audio_messages/LiveRecordingWaveform.tsx b/src/components/views/audio_messages/LiveRecordingWaveform.tsx index 18c1ca1aa2..2c388fe867 100644 --- a/src/components/views/audio_messages/LiveRecordingWaveform.tsx +++ b/src/components/views/audio_messages/LiveRecordingWaveform.tsx @@ -8,11 +8,11 @@ Please see LICENSE files in the repository root for full details. import React from "react"; -import { IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES } from "../../../audio/VoiceRecording"; +import { type IRecordingUpdate, RECORDING_PLAYBACK_SAMPLES } from "../../../audio/VoiceRecording"; import { arrayFastResample, arraySeed } from "../../../utils/arrays"; import Waveform from "./Waveform"; import { MarkedExecution } from "../../../utils/MarkedExecution"; -import { VoiceMessageRecording } from "../../../audio/VoiceMessageRecording"; +import { type VoiceMessageRecording } from "../../../audio/VoiceMessageRecording"; interface IProps { recorder: VoiceMessageRecording; diff --git a/src/components/views/audio_messages/PlayPauseButton.tsx b/src/components/views/audio_messages/PlayPauseButton.tsx index 1b197c6bad..a4457b2230 100644 --- a/src/components/views/audio_messages/PlayPauseButton.tsx +++ b/src/components/views/audio_messages/PlayPauseButton.tsx @@ -6,12 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ReactNode } from "react"; +import React, { type ReactNode } from "react"; import classNames from "classnames"; import { _t } from "../../../languageHandler"; -import { Playback, PlaybackState } from "../../../audio/Playback"; -import AccessibleButton, { ButtonProps } from "../elements/AccessibleButton"; +import { type Playback, PlaybackState } from "../../../audio/Playback"; +import AccessibleButton, { type ButtonProps } from "../elements/AccessibleButton"; type Props = Omit, "title" | "onClick" | "disabled" | "element" | "ref"> & { // Playback instance to manipulate. Cannot change during the component lifecycle. diff --git a/src/components/views/audio_messages/PlaybackClock.tsx b/src/components/views/audio_messages/PlaybackClock.tsx index 999b5398b1..0d69793379 100644 --- a/src/components/views/audio_messages/PlaybackClock.tsx +++ b/src/components/views/audio_messages/PlaybackClock.tsx @@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import Clock from "./Clock"; -import { Playback, PlaybackState } from "../../../audio/Playback"; +import { type Playback, PlaybackState } from "../../../audio/Playback"; import { UPDATE_EVENT } from "../../../stores/AsyncStore"; interface IProps { diff --git a/src/components/views/audio_messages/PlaybackWaveform.tsx b/src/components/views/audio_messages/PlaybackWaveform.tsx index a5113dd042..c1f470f4b1 100644 --- a/src/components/views/audio_messages/PlaybackWaveform.tsx +++ b/src/components/views/audio_messages/PlaybackWaveform.tsx @@ -10,7 +10,7 @@ import React from "react"; import { arraySeed, arrayTrimFill } from "../../../utils/arrays"; import Waveform from "./Waveform"; -import { Playback } from "../../../audio/Playback"; +import { type Playback } from "../../../audio/Playback"; import { percentageOf } from "../../../utils/numbers"; import { PLAYBACK_WAVEFORM_SAMPLES } from "../../../audio/consts"; diff --git a/src/components/views/audio_messages/RecordingPlayback.tsx b/src/components/views/audio_messages/RecordingPlayback.tsx index 4c030f81ef..c0e3337787 100644 --- a/src/components/views/audio_messages/RecordingPlayback.tsx +++ b/src/components/views/audio_messages/RecordingPlayback.tsx @@ -6,11 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ReactNode } from "react"; +import React, { type ReactNode } from "react"; import PlayPauseButton from "./PlayPauseButton"; import PlaybackClock from "./PlaybackClock"; -import AudioPlayerBase, { IProps as IAudioPlayerBaseProps } from "./AudioPlayerBase"; +import AudioPlayerBase, { type IProps as IAudioPlayerBaseProps } from "./AudioPlayerBase"; import SeekBar from "./SeekBar"; import PlaybackWaveform from "./PlaybackWaveform"; import { PlaybackState } from "../../../audio/Playback"; diff --git a/src/components/views/audio_messages/SeekBar.tsx b/src/components/views/audio_messages/SeekBar.tsx index 1a79f5be06..587975ce1b 100644 --- a/src/components/views/audio_messages/SeekBar.tsx +++ b/src/components/views/audio_messages/SeekBar.tsx @@ -6,9 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ChangeEvent, CSSProperties, ReactNode } from "react"; +import React, { type ChangeEvent, type CSSProperties, type ReactNode } from "react"; -import { PlaybackInterface } from "../../../audio/Playback"; +import { type PlaybackInterface } from "../../../audio/Playback"; import { MarkedExecution } from "../../../utils/MarkedExecution"; import { percentageOf } from "../../../utils/numbers"; import { _t } from "../../../languageHandler"; diff --git a/src/components/views/audio_messages/Waveform.tsx b/src/components/views/audio_messages/Waveform.tsx index 83d02b81fd..115b310f4c 100644 --- a/src/components/views/audio_messages/Waveform.tsx +++ b/src/components/views/audio_messages/Waveform.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { CSSProperties } from "react"; +import React, { type CSSProperties } from "react"; import classNames from "classnames"; interface WaveformCSSProperties extends CSSProperties { @@ -18,8 +18,6 @@ interface IProps { progress: number; // percent complete, 0-1, default 100% } -interface IState {} - /** * A simple waveform component. This renders bars (centered vertically) for each * height provided in the component properties. Updating the properties will update @@ -28,7 +26,7 @@ interface IState {} * For CSS purposes, a mx_Waveform_bar_100pct class is added when the bar should be * "filled", as a demonstration of the progress property. */ -export default class Waveform extends React.PureComponent { +export default class Waveform extends React.PureComponent { public static defaultProps = { progress: 1, }; diff --git a/src/components/views/auth/AuthBody.tsx b/src/components/views/auth/AuthBody.tsx index b83955fcd9..98ed548584 100644 --- a/src/components/views/auth/AuthBody.tsx +++ b/src/components/views/auth/AuthBody.tsx @@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details. */ import classNames from "classnames"; -import React, { PropsWithChildren } from "react"; +import React, { type JSX, type PropsWithChildren } from "react"; interface Props { className?: string; diff --git a/src/components/views/auth/AuthFooter.tsx b/src/components/views/auth/AuthFooter.tsx index 472ff53f09..1942bf0431 100644 --- a/src/components/views/auth/AuthFooter.tsx +++ b/src/components/views/auth/AuthFooter.tsx @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ReactElement } from "react"; +import React, { type JSX, type ReactElement } from "react"; import SdkConfig from "../../../SdkConfig"; import { _t } from "../../../languageHandler"; diff --git a/src/components/views/auth/CaptchaForm.tsx b/src/components/views/auth/CaptchaForm.tsx index d019ba234c..ca25ae180d 100644 --- a/src/components/views/auth/CaptchaForm.tsx +++ b/src/components/views/auth/CaptchaForm.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { createRef } from "react"; +import React, { type JSX, createRef } from "react"; import { logger } from "matrix-js-sdk/src/logger"; import { _t } from "../../../languageHandler"; diff --git a/src/components/views/auth/CompleteSecurityBody.tsx b/src/components/views/auth/CompleteSecurityBody.tsx index 77808ec71c..01eb0ec6f5 100644 --- a/src/components/views/auth/CompleteSecurityBody.tsx +++ b/src/components/views/auth/CompleteSecurityBody.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ReactNode } from "react"; +import React, { type ReactNode } from "react"; export default class CompleteSecurityBody extends React.PureComponent<{ children: ReactNode }> { public render(): React.ReactNode { diff --git a/src/components/views/auth/CountryDropdown.tsx b/src/components/views/auth/CountryDropdown.tsx index 7e8d669d15..f25ed95e96 100644 --- a/src/components/views/auth/CountryDropdown.tsx +++ b/src/components/views/auth/CountryDropdown.tsx @@ -6,13 +6,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ReactElement } from "react"; +import React, { type ReactElement } from "react"; -import { COUNTRIES, getEmojiFlag, PhoneNumberCountryDefinition } from "../../../phonenumber"; +import { COUNTRIES, getEmojiFlag, type PhoneNumberCountryDefinition } from "../../../phonenumber"; import SdkConfig from "../../../SdkConfig"; import { _t, getUserLanguage } from "../../../languageHandler"; import Dropdown from "../elements/Dropdown"; -import { NonEmptyArray } from "../../../@types/common"; +import { type NonEmptyArray } from "../../../@types/common"; interface InternationalisedCountry extends PhoneNumberCountryDefinition { name: string; // already translated to the user's locale diff --git a/src/components/views/auth/EmailField.tsx b/src/components/views/auth/EmailField.tsx index ddc20c0bd1..fb420ed459 100644 --- a/src/components/views/auth/EmailField.tsx +++ b/src/components/views/auth/EmailField.tsx @@ -6,16 +6,16 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ComponentProps, PureComponent, RefCallback, RefObject } from "react"; +import React, { type ComponentProps, PureComponent, type Ref } from "react"; -import Field, { IInputProps } from "../elements/Field"; -import { _t, _td, TranslationKey } from "../../../languageHandler"; -import withValidation, { IFieldState, IValidationResult } from "../elements/Validation"; +import Field, { type IInputProps } from "../elements/Field"; +import { _t, _td, type TranslationKey } from "../../../languageHandler"; +import withValidation, { type IFieldState, type IValidationResult } from "../elements/Validation"; import * as Email from "../../../email"; interface IProps extends Omit { id?: string; - fieldRef?: RefCallback | RefObject; + fieldRef?: Ref; value: string; autoFocus?: boolean; diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index d493e5c3ca..a9bf8b7597 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -7,23 +7,24 @@ Please see LICENSE files in the repository root for full details. */ import classNames from "classnames"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { AuthType, AuthDict, IInputs, IStageStatus } from "matrix-js-sdk/src/interactive-auth"; +import { type InternationalisedPolicy, type Terms, type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { AuthType, type AuthDict, type IInputs, type IStageStatus } from "matrix-js-sdk/src/interactive-auth"; import { logger } from "matrix-js-sdk/src/logger"; -import React, { ChangeEvent, createRef, FormEvent, Fragment } from "react"; -import { Button, Text } from "@vector-im/compound-web"; +import React, { type JSX, type ChangeEvent, createRef, type FormEvent, Fragment } from "react"; +import { Button } from "@vector-im/compound-web"; import PopOutIcon from "@vector-im/compound-design-tokens/assets/web/icons/pop-out"; +import UserProfileSolidIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-profile-solid"; import EmailPromptIcon from "../../../../res/img/element-icons/email-prompt.svg"; import { _t } from "../../../languageHandler"; -import SettingsStore from "../../../settings/SettingsStore"; -import { LocalisedPolicy, Policies } from "../../../Terms"; import { AuthHeaderModifier } from "../../structures/auth/header/AuthHeaderModifier"; -import AccessibleButton, { AccessibleButtonKind, ButtonEvent } from "../elements/AccessibleButton"; +import AccessibleButton, { type AccessibleButtonKind, type ButtonEvent } from "../elements/AccessibleButton"; import Field from "../elements/Field"; import Spinner from "../elements/Spinner"; import CaptchaForm from "./CaptchaForm"; -import { Flex } from "../../utils/Flex"; +import { pickBestPolicyLanguage } from "../../../Terms.ts"; +import { EncryptionCardButtons } from "../settings/encryption/EncryptionCardButtons.tsx"; +import { EncryptionCard } from "../settings/encryption/EncryptionCard.tsx"; /* This file contains a collection of components which are used by the * InteractiveAuth to prompt the user to enter the information needed @@ -86,7 +87,6 @@ interface IAuthEntryProps { requestEmailToken?: () => Promise; fail: (error: Error) => void; clientSecret: string; - showContinue: boolean; } interface IPasswordAuthEntryState { @@ -235,12 +235,10 @@ export class RecaptchaAuthEntry extends React.Component; } -interface LocalisedPolicyWithId extends LocalisedPolicy { +interface LocalisedPolicyWithId extends InternationalisedPolicy { id: string; } @@ -278,7 +276,6 @@ export class TermsAuthEntry extends React.Component = {}; const pickedPolicies: { id: string; @@ -287,17 +284,7 @@ export class TermsAuthEntry extends React.Component e !== "version"); - langPolicy = firstLang ? policy[firstLang] : undefined; - } + const langPolicy = pickBestPolicyLanguage(policy); if (!langPolicy) throw new Error("Failed to find a policy to show the user"); initToggles[policyId] = false; @@ -375,9 +362,11 @@ export class TermsAuthEntry extends React.Component +

    {_t("auth|uia|terms")}

    + {checkboxes} + {errorSection} {_t("action|accept")} - ); - } - - return ( -
    -

    {_t("auth|uia|terms")}

    - {checkboxes} - {errorSection} - {submitButton}
    ); } @@ -908,7 +888,7 @@ export class SSOAuthEntry extends React.Component extends React.Component { +export class FallbackAuthEntry extends React.Component { protected popupWindow: Window | null; protected fallbackButton = createRef(); @@ -993,9 +973,14 @@ export class MasUnlockCrossSigningAuthEntry extends FallbackAuthEntry<{ public render(): React.ReactNode { return ( -
    - {_t("auth|uia|mas_cross_signing_reset_description")} - + + - - -
    + + ); } } diff --git a/src/components/views/auth/LanguageSelector.tsx b/src/components/views/auth/LanguageSelector.tsx index a3be10eeec..e8dbf0cf4e 100644 --- a/src/components/views/auth/LanguageSelector.tsx +++ b/src/components/views/auth/LanguageSelector.tsx @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React from "react"; +import React, { type JSX } from "react"; import SdkConfig from "../../../SdkConfig"; import { getCurrentLanguage } from "../../../languageHandler"; diff --git a/src/components/views/auth/LoginWithQR.tsx b/src/components/views/auth/LoginWithQR.tsx index ecf107cfbd..1519a1fb45 100644 --- a/src/components/views/auth/LoginWithQR.tsx +++ b/src/components/views/auth/LoginWithQR.tsx @@ -14,11 +14,11 @@ import { MSC4108SecureChannel, MSC4108SignInWithQR, RendezvousError, - RendezvousFailureReason, + type RendezvousFailureReason, RendezvousIntent, } from "matrix-js-sdk/src/rendezvous"; import { logger } from "matrix-js-sdk/src/logger"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { Click, Mode, Phase } from "./LoginWithQR-types"; import LoginWithQRFlow from "./LoginWithQRFlow"; diff --git a/src/components/views/auth/LoginWithQRFlow.tsx b/src/components/views/auth/LoginWithQRFlow.tsx index 663dc1acff..3a5a88f78d 100644 --- a/src/components/views/auth/LoginWithQRFlow.tsx +++ b/src/components/views/auth/LoginWithQRFlow.tsx @@ -6,11 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { createRef, ReactNode } from "react"; +import React, { type JSX, createRef, type ReactNode } from "react"; import { ClientRendezvousFailureReason, MSC4108FailureReason } from "matrix-js-sdk/src/rendezvous"; import ChevronLeftIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-left"; import CheckCircleSolidIcon from "@vector-im/compound-design-tokens/assets/web/icons/check-circle-solid"; -import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error"; +import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid"; import { Heading, MFAInput, Text } from "@vector-im/compound-web"; import classNames from "classnames"; import { QrCodeIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; @@ -21,7 +21,7 @@ import QRCode from "../elements/QRCode"; import Spinner from "../elements/Spinner"; import { Click, Phase } from "./LoginWithQR-types"; import SdkConfig from "../../../SdkConfig"; -import { FailureReason, LoginWithQRFailureReason } from "./LoginWithQR"; +import { type FailureReason, LoginWithQRFailureReason } from "./LoginWithQR"; import { ErrorMessage } from "../../structures/ErrorMessage"; interface Props { diff --git a/src/components/views/auth/PassphraseConfirmField.tsx b/src/components/views/auth/PassphraseConfirmField.tsx index 0337c80359..4dc720e8af 100644 --- a/src/components/views/auth/PassphraseConfirmField.tsx +++ b/src/components/views/auth/PassphraseConfirmField.tsx @@ -6,15 +6,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ComponentProps, PureComponent, RefCallback, RefObject } from "react"; +import React, { type ComponentProps, PureComponent, type Ref } from "react"; -import Field, { IInputProps } from "../elements/Field"; -import withValidation, { IFieldState, IValidationResult } from "../elements/Validation"; -import { _t, _td, TranslationKey } from "../../../languageHandler"; +import Field, { type IInputProps } from "../elements/Field"; +import withValidation, { type IFieldState, type IValidationResult } from "../elements/Validation"; +import { _t, _td, type TranslationKey } from "../../../languageHandler"; interface IProps extends Omit { id?: string; - fieldRef?: RefCallback | RefObject; + fieldRef?: Ref; autoComplete?: string; value: string; password: string; // The password we're confirming diff --git a/src/components/views/auth/PassphraseField.tsx b/src/components/views/auth/PassphraseField.tsx index 8fc56cc68c..938e559fd7 100644 --- a/src/components/views/auth/PassphraseField.tsx +++ b/src/components/views/auth/PassphraseField.tsx @@ -6,14 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ComponentProps, PureComponent, RefCallback, RefObject } from "react"; +import React, { type ComponentProps, PureComponent, type Ref } from "react"; import classNames from "classnames"; import type { ZxcvbnResult } from "@zxcvbn-ts/core"; import SdkConfig from "../../../SdkConfig"; -import withValidation, { IFieldState, IValidationResult } from "../elements/Validation"; -import { _t, _td, TranslationKey } from "../../../languageHandler"; -import Field, { IInputProps } from "../elements/Field"; +import withValidation, { type IFieldState, type IValidationResult } from "../elements/Validation"; +import { _t, _td, type TranslationKey } from "../../../languageHandler"; +import Field, { type IInputProps } from "../elements/Field"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; interface IProps extends Omit { @@ -22,7 +22,7 @@ interface IProps extends Omit { className?: string; minScore: 0 | 1 | 2 | 3 | 4; value: string; - fieldRef?: RefCallback | RefObject; + fieldRef?: Ref; // Additional strings such as a username used to catch bad passwords userInputs?: string[]; diff --git a/src/components/views/auth/PasswordLogin.tsx b/src/components/views/auth/PasswordLogin.tsx index e13af99f6f..a3b77c60f4 100644 --- a/src/components/views/auth/PasswordLogin.tsx +++ b/src/components/views/auth/PasswordLogin.tsx @@ -6,18 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { SyntheticEvent } from "react"; +import React, { type JSX, type SyntheticEvent } from "react"; import classNames from "classnames"; import { _t } from "../../../languageHandler"; import SdkConfig from "../../../SdkConfig"; -import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig"; -import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; -import withValidation, { IFieldState, IValidationResult } from "../elements/Validation"; +import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig"; +import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; +import withValidation, { type IFieldState, type IValidationResult } from "../elements/Validation"; import Field from "../elements/Field"; import CountryDropdown from "./CountryDropdown"; import EmailField from "./EmailField"; -import { PhoneNumberCountryDefinition } from "../../../phonenumber"; +import { type PhoneNumberCountryDefinition } from "../../../phonenumber"; // For validating phone numbers without country codes const PHONE_NUMBER_REGEX = /^[0-9()\-\s]*$/; @@ -427,7 +427,9 @@ export default class PasswordLogin extends React.PureComponent { disabled={this.props.busy} autoFocus={autoFocusPassword} onValidate={this.onPasswordValidate} - ref={(field) => (this[LoginField.Password] = field)} + ref={(field) => { + this[LoginField.Password] = field; + }} /> {forgotPasswordJsx} {!this.props.busy && ( diff --git a/src/components/views/auth/RegistrationForm.tsx b/src/components/views/auth/RegistrationForm.tsx index ef2c83d3be..88e9aa0615 100644 --- a/src/components/views/auth/RegistrationForm.tsx +++ b/src/components/views/auth/RegistrationForm.tsx @@ -7,18 +7,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { BaseSyntheticEvent, ComponentProps, ReactNode } from "react"; -import { MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; +import React, { type JSX, type BaseSyntheticEvent, type ComponentProps, type ReactNode } from "react"; +import { type MatrixClient, MatrixError } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import * as Email from "../../../email"; -import { looksValid as phoneNumberLooksValid, PhoneNumberCountryDefinition } from "../../../phonenumber"; +import { looksValid as phoneNumberLooksValid, type PhoneNumberCountryDefinition } from "../../../phonenumber"; import Modal from "../../../Modal"; import { _t, _td } from "../../../languageHandler"; import SdkConfig from "../../../SdkConfig"; import { SAFE_LOCALPART_REGEX } from "../../../Registration"; -import withValidation, { IFieldState, IValidationResult } from "../elements/Validation"; -import { ValidatedServerConfig } from "../../../utils/ValidatedServerConfig"; +import withValidation, { type IFieldState, type IValidationResult } from "../elements/Validation"; +import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig"; import EmailField from "./EmailField"; import PassphraseField from "./PassphraseField"; import Field from "../elements/Field"; @@ -127,19 +127,11 @@ export default class RegistrationForm extends React.PureComponent => { - if (confirmed && email !== undefined) { - this.setState( - { - email, - }, - () => { - this.doSubmit(ev); - }, - ); - } - }, + const { finished } = Modal.createDialog(RegistrationEmailPromptDialog); + finished.then(async ([confirmed, email]) => { + if (confirmed && email !== undefined) { + this.setState({ email }, () => this.doSubmit(ev)); + } }); } else { // user can't set an e-mail so don't prompt them to @@ -456,7 +448,9 @@ export default class RegistrationForm extends React.PureComponent (this[RegistrationField.Email] = field)} + fieldRef={(field) => { + this[RegistrationField.Email] = field; + }} label={emailLabel} value={this.state.email} validationRules={this.validateEmailRules.bind(this)} @@ -471,7 +465,9 @@ export default class RegistrationForm extends React.PureComponent (this[RegistrationField.Password] = field)} + fieldRef={(field) => { + this[RegistrationField.Password] = field; + }} minScore={PASSWORD_MIN_SCORE} value={this.state.password} onChange={this.onPasswordChange} @@ -486,7 +482,9 @@ export default class RegistrationForm extends React.PureComponent (this[RegistrationField.PasswordConfirm] = field)} + fieldRef={(field) => { + this[RegistrationField.PasswordConfirm] = field; + }} autoComplete="new-password" value={this.state.passwordConfirm} password={this.state.password} @@ -514,7 +512,9 @@ export default class RegistrationForm extends React.PureComponent (this[RegistrationField.PhoneNumber] = field)} + ref={(field) => { + this[RegistrationField.PhoneNumber] = field; + }} type="text" label={phoneLabel} value={this.state.phoneNumber} @@ -529,7 +529,9 @@ export default class RegistrationForm extends React.PureComponent (this[RegistrationField.Username] = field)} + ref={(field) => { + this[RegistrationField.Username] = field; + }} type="text" autoFocus={true} label={_t("common|username")} diff --git a/src/components/views/auth/Welcome.tsx b/src/components/views/auth/Welcome.tsx index 39d5f95a8d..59fd1533e3 100644 --- a/src/components/views/auth/Welcome.tsx +++ b/src/components/views/auth/Welcome.tsx @@ -7,6 +7,7 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import classNames from "classnames"; +import { type EmptyObject } from "matrix-js-sdk/src/matrix"; import SdkConfig from "../../../SdkConfig"; import AuthPage from "./AuthPage"; @@ -16,9 +17,7 @@ import LanguageSelector from "./LanguageSelector"; import EmbeddedPage from "../../structures/EmbeddedPage"; import { MATRIX_LOGO_HTML } from "../../structures/static-page-vars"; -interface IProps {} - -export default class Welcome extends React.PureComponent { +export default class Welcome extends React.PureComponent { public render(): React.ReactNode { const pagesConfig = SdkConfig.getObject("embedded_pages"); let pageUrl: string | undefined; diff --git a/src/components/views/avatars/BaseAvatar.tsx b/src/components/views/avatars/BaseAvatar.tsx index 942650c65a..0837bf1dc8 100644 --- a/src/components/views/avatars/BaseAvatar.tsx +++ b/src/components/views/avatars/BaseAvatar.tsx @@ -9,13 +9,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { AriaRole, forwardRef, useCallback, useContext, useEffect, useState } from "react"; +import React, { type AriaRole, type JSX, type Ref, useCallback, useContext, useEffect, useState } from "react"; import classNames from "classnames"; -import { ClientEvent, SyncState } from "matrix-js-sdk/src/matrix"; +import { ClientEvent, type SyncState } from "matrix-js-sdk/src/matrix"; import { Avatar } from "@vector-im/compound-web"; import SettingsStore from "../../../settings/SettingsStore"; -import { ButtonEvent } from "../elements/AccessibleButton"; +import { type ButtonEvent } from "../elements/AccessibleButton"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; import { _t } from "../../../languageHandler"; @@ -34,6 +34,7 @@ interface IProps { tabIndex?: number; altText?: string; role?: AriaRole; + ref?: Ref; } const calculateUrls = (url?: string | null, urls?: string[], lowBandwidth = false): string[] => { @@ -87,7 +88,7 @@ const useImageUrl = ({ url, urls }: { url?: string | null; urls?: string[] }): [ return [imageUrl, onError]; }; -const BaseAvatar = forwardRef((props, ref) => { +const BaseAvatar = (props: IProps): JSX.Element => { const { name, idName, @@ -99,6 +100,7 @@ const BaseAvatar = forwardRef((props, ref) => { className, type = "round", altText = _t("common|avatar"), + ref, ...otherProps } = props; @@ -134,7 +136,7 @@ const BaseAvatar = forwardRef((props, ref) => { data-testid="avatar-img" /> ); -}); +}; export default BaseAvatar; export type BaseAvatarType = React.FC; diff --git a/src/components/views/avatars/DecoratedRoomAvatar.tsx b/src/components/views/avatars/DecoratedRoomAvatar.tsx index 5a11e0f6b2..321c0501dc 100644 --- a/src/components/views/avatars/DecoratedRoomAvatar.tsx +++ b/src/components/views/avatars/DecoratedRoomAvatar.tsx @@ -6,21 +6,29 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React from "react"; +import React, { type JSX } from "react"; import classNames from "classnames"; -import { EventType, JoinRule, MatrixEvent, Room, RoomEvent, User, UserEvent } from "matrix-js-sdk/src/matrix"; +import { + EventType, + JoinRule, + type MatrixEvent, + type Room, + RoomEvent, + type User, + UserEvent, +} from "matrix-js-sdk/src/matrix"; import { UnstableValue } from "matrix-js-sdk/src/NamespacedValue"; import { Tooltip } from "@vector-im/compound-web"; import RoomAvatar from "./RoomAvatar"; import NotificationBadge from "../rooms/NotificationBadge"; import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore"; -import { NotificationState } from "../../../stores/notifications/NotificationState"; +import { type NotificationState } from "../../../stores/notifications/NotificationState"; import { isPresenceEnabled } from "../../../utils/presence"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { _t } from "../../../languageHandler"; import DMRoomMap from "../../../utils/DMRoomMap"; -import { IOOBData } from "../../../stores/ThreepidInviteStore"; +import { type IOOBData } from "../../../stores/ThreepidInviteStore"; import { getJoinedNonFunctionalMembers } from "../../../utils/room/getJoinedNonFunctionalMembers"; interface IProps { @@ -71,6 +79,9 @@ function tooltipText(variant: Icon): string | undefined { } } +/** + * @deprecated Use {@link RoomAvatarView} instead. + */ export default class DecoratedRoomAvatar extends React.PureComponent { private _dmUser: User | null = null; private isUnmounted = false; diff --git a/src/components/views/avatars/MemberAvatar.tsx b/src/components/views/avatars/MemberAvatar.tsx index 641fe4a783..a62594f038 100644 --- a/src/components/views/avatars/MemberAvatar.tsx +++ b/src/components/views/avatars/MemberAvatar.tsx @@ -7,8 +7,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { forwardRef, ReactNode, Ref, useContext } from "react"; -import { RoomMember, ResizeMethod } from "matrix-js-sdk/src/matrix"; +import React, { type JSX, type ReactNode, type Ref, useContext } from "react"; +import { type RoomMember, type ResizeMethod } from "matrix-js-sdk/src/matrix"; import dis from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; @@ -18,6 +18,7 @@ import { CardContext } from "../right_panel/context"; import UserIdentifierCustomisations from "../../../customisations/UserIdentifier"; import { useRoomMemberProfile } from "../../../hooks/room/useRoomMemberProfile"; import { _t } from "../../../languageHandler"; +import MatrixClientContext from "../../../contexts/MatrixClientContext.tsx"; interface IProps extends Omit, "name" | "idName" | "url"> { member: RoomMember | null; @@ -32,21 +33,21 @@ interface IProps extends Omit, "name" | forceHistorical?: boolean; // true to deny `useOnlyCurrentProfiles` usage. Default false. hideTitle?: boolean; children?: ReactNode; + ref?: Ref; } -function MemberAvatar( - { - size, - resizeMethod = "crop", - viewUserOnClick, - forceHistorical, - fallbackUserId, - hideTitle, - member: propsMember, - ...props - }: IProps, - ref: Ref, -): JSX.Element { +export default function MemberAvatar({ + size, + resizeMethod = "crop", + viewUserOnClick, + forceHistorical, + fallbackUserId, + hideTitle, + member: propsMember, + ref, + ...props +}: IProps): JSX.Element { + const cli = useContext(MatrixClientContext); const card = useContext(CardContext); const member = useRoomMemberProfile({ @@ -60,7 +61,7 @@ function MemberAvatar( let imageUrl: string | null | undefined; if (member?.name) { if (member.getMxcAvatarUrl()) { - imageUrl = mediaFromMxc(member.getMxcAvatarUrl() ?? "").getThumbnailOfSourceHttp( + imageUrl = mediaFromMxc(member.getMxcAvatarUrl() ?? "", cli).getThumbnailOfSourceHttp( parseInt(size, 10), parseInt(size, 10), resizeMethod, @@ -99,5 +100,3 @@ function MemberAvatar( /> ); } - -export default forwardRef(MemberAvatar); diff --git a/src/components/views/avatars/RoomAvatar.tsx b/src/components/views/avatars/RoomAvatar.tsx index e1d71ac1aa..6490378731 100644 --- a/src/components/views/avatars/RoomAvatar.tsx +++ b/src/components/views/avatars/RoomAvatar.tsx @@ -1,146 +1,97 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024, 2025 New Vector Ltd. Copyright 2015, 2016 OpenMarket Ltd SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ -import React, { ComponentProps } from "react"; -import { Room, RoomStateEvent, MatrixEvent, EventType, RoomType } from "matrix-js-sdk/src/matrix"; +import React, { useCallback, useMemo, type ComponentProps } from "react"; +import { type Room, RoomType, KnownMembership, EventType } from "matrix-js-sdk/src/matrix"; +import { type RoomAvatarEventContent } from "matrix-js-sdk/src/types"; import BaseAvatar from "./BaseAvatar"; import ImageView from "../elements/ImageView"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; import Modal from "../../../Modal"; import * as Avatar from "../../../Avatar"; -import DMRoomMap from "../../../utils/DMRoomMap"; import { mediaFromMxc } from "../../../customisations/Media"; -import { IOOBData } from "../../../stores/ThreepidInviteStore"; -import { LocalRoom } from "../../../models/LocalRoom"; +import { type IOOBData } from "../../../stores/ThreepidInviteStore"; import { filterBoolean } from "../../../utils/arrays"; +import { useSettingValue } from "../../../hooks/useSettings"; +import { useRoomState } from "../../../hooks/useRoomState"; +import { useRoomIdName } from "../../../hooks/room/useRoomIdName"; +import { MediaPreviewValue } from "../../../@types/media_preview"; -interface IProps extends Omit, "name" | "idName" | "url" | "onClick"> { +interface IProps extends Omit, "name" | "idName" | "url" | "onClick" | "size"> { // Room may be left unset here, but if it is, // oobData.avatarUrl should be set (else there // would be nowhere to get the avatar from) room?: Room; - oobData: IOOBData & { + // Optional here. + size?: ComponentProps["size"]; + oobData?: IOOBData & { roomId?: string; }; viewAvatarOnClick?: boolean; onClick?(): void; } -interface IState { - urls: string[]; -} +const RoomAvatar: React.FC = ({ room, viewAvatarOnClick, onClick, oobData, size = "36px", ...otherProps }) => { + const roomName = room?.name ?? oobData?.name ?? "?"; + const avatarEvent = useRoomState(room, (state) => state.getStateEvents(EventType.RoomAvatar, "")); + const roomIdName = useRoomIdName(room, oobData); -export function idNameForRoom(room: Room): string { - const dmMapUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId); - // If the room is a DM, we use the other user's ID for the color hash - // in order to match the room avatar with their avatar - if (dmMapUserId) return dmMapUserId; + const showAvatarsOnInvites = + useSettingValue("mediaPreviewConfig", room?.roomId).invite_avatars === MediaPreviewValue.On; - if (room instanceof LocalRoom && room.targets.length === 1) { - return room.targets[0].userId; - } - - return room.roomId; -} - -export default class RoomAvatar extends React.Component { - public static defaultProps = { - size: "36px", - oobData: {}, - }; - - public constructor(props: IProps) { - super(props); - - this.state = { - urls: RoomAvatar.getImageUrls(this.props), + const onRoomAvatarClick = useCallback(() => { + const avatarUrl = Avatar.avatarUrlForRoom(room ?? null); + if (!avatarUrl) return; + const params = { + src: avatarUrl, + name: room?.name, }; - } - public componentDidMount(): void { - MatrixClientPeg.safeGet().on(RoomStateEvent.Events, this.onRoomStateEvents); - } + Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true); + }, [room]); - public componentWillUnmount(): void { - MatrixClientPeg.get()?.removeListener(RoomStateEvent.Events, this.onRoomStateEvents); - } + const urls = useMemo(() => { + const myMembership = room?.getMyMembership(); + if (!showAvatarsOnInvites && (myMembership === KnownMembership.Invite || !myMembership)) { + // The user has opted out of showing avatars, so return no urls here. + return []; + } - public static getDerivedStateFromProps(nextProps: IProps): IState { - return { - urls: RoomAvatar.getImageUrls(nextProps), - }; - } - - private onRoomStateEvents = (ev: MatrixEvent): void => { - if (ev.getRoomId() !== this.props.room?.roomId || ev.getType() !== EventType.RoomAvatar) return; - - this.setState({ - urls: RoomAvatar.getImageUrls(this.props), - }); - }; - - private static getImageUrls(props: IProps): string[] { + // parseInt ignores suffixes. + const sizeInt = parseInt(size, 10); let oobAvatar: string | null = null; - if (props.oobData.avatarUrl) { - oobAvatar = mediaFromMxc(props.oobData.avatarUrl).getThumbnailOfSourceHttp( - parseInt(props.size, 10), - parseInt(props.size, 10), - "crop", - ); + if (oobData?.avatarUrl) { + oobAvatar = mediaFromMxc(oobData?.avatarUrl).getThumbnailOfSourceHttp(sizeInt, sizeInt, "crop"); } return filterBoolean([ oobAvatar, // highest priority - RoomAvatar.getRoomAvatarUrl(props), + Avatar.avatarUrlForRoom( + room ?? null, + sizeInt, + sizeInt, + "crop", + avatarEvent?.getContent().url, + ), ]); - } + }, [showAvatarsOnInvites, room, size, avatarEvent, oobData]); - private static getRoomAvatarUrl(props: IProps): string | null { - if (!props.room) return null; + return ( + + ); +}; - return Avatar.avatarUrlForRoom(props.room, parseInt(props.size, 10), parseInt(props.size, 10), "crop"); - } - - private onRoomAvatarClick = (): void => { - const avatarUrl = Avatar.avatarUrlForRoom(this.props.room ?? null, undefined, undefined, undefined); - if (!avatarUrl) return; - const params = { - src: avatarUrl, - name: this.props.room?.name, - }; - - Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true); - }; - - private get roomIdName(): string | undefined { - const room = this.props.room; - - if (room) { - return idNameForRoom(room); - } else { - return this.props.oobData?.roomId; - } - } - - public render(): React.ReactNode { - const { room, oobData, viewAvatarOnClick, onClick, className, ...otherProps } = this.props; - const roomName = room?.name ?? oobData.name ?? "?"; - - return ( - - ); - } -} +export default RoomAvatar; diff --git a/src/components/views/avatars/RoomAvatarView.tsx b/src/components/views/avatars/RoomAvatarView.tsx new file mode 100644 index 0000000000..8810d073c5 --- /dev/null +++ b/src/components/views/avatars/RoomAvatarView.tsx @@ -0,0 +1,128 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX } from "react"; +import { type Room } from "matrix-js-sdk/src/matrix"; +import PublicIcon from "@vector-im/compound-design-tokens/assets/web/icons/public"; +import VideoIcon from "@vector-im/compound-design-tokens/assets/web/icons/video-call-solid"; +import OnlineOrUnavailableIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-solid-8x8"; +import OfflineIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-outline-8x8"; +import BusyIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-strikethrough-8x8"; +import classNames from "classnames"; + +import RoomAvatar from "./RoomAvatar"; +import { useRoomAvatarViewModel } from "../../viewmodels/avatars/RoomAvatarViewModel"; +import { _t } from "../../../languageHandler"; +import { Presence } from "./WithPresenceIndicator"; + +interface RoomAvatarViewProps { + /** + * The room to display the avatar for. + */ + room: Room; +} + +/** + * Component to display the avatar of a room. + * Currently only 32px size is supported. + */ +export function RoomAvatarView({ room }: RoomAvatarViewProps): JSX.Element { + const vm = useRoomAvatarViewModel(room); + // No decoration, we just show the avatar + if (!vm.hasDecoration) return ; + + return ( +
    + + + {/* If the room is a public video room, we prefer to display only the video icon */} + {vm.isPublic && !vm.isVideoRoom && ( + + )} + {vm.isVideoRoom && ( + + )} + {vm.presence && } +
    + ); +} + +type PresenceDecorationProps = { + /** + * The presence of the user in the DM room. + */ + presence: NonNullable; +}; + +/** + * Component to display the presence of a user in a DM room. + */ +function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element { + switch (presence) { + case Presence.Online: + return ( + + ); + case Presence.Away: + return ( + + ); + case Presence.Offline: + return ( + + ); + case Presence.Busy: + return ( + + ); + } +} diff --git a/src/components/views/avatars/SearchResultAvatar.tsx b/src/components/views/avatars/SearchResultAvatar.tsx index c50c4d81b2..45c85717d2 100644 --- a/src/components/views/avatars/SearchResultAvatar.tsx +++ b/src/components/views/avatars/SearchResultAvatar.tsx @@ -6,12 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React from "react"; -import { RoomMember } from "matrix-js-sdk/src/matrix"; +import React, { type JSX } from "react"; +import { type RoomMember } from "matrix-js-sdk/src/matrix"; import emailPillAvatar from "../../../../res/img/icon-email-pill-avatar.svg"; import { mediaFromMxc } from "../../../customisations/Media"; -import { Member, ThreepidMember } from "../../../utils/direct-messages"; +import { type Member, type ThreepidMember } from "../../../utils/direct-messages"; import BaseAvatar from "./BaseAvatar"; interface SearchResultAvatarProps { diff --git a/src/components/views/avatars/WidgetAvatar.tsx b/src/components/views/avatars/WidgetAvatar.tsx index f6d73e7d1c..c43cd98216 100644 --- a/src/components/views/avatars/WidgetAvatar.tsx +++ b/src/components/views/avatars/WidgetAvatar.tsx @@ -6,12 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ComponentProps } from "react"; -import { IWidget } from "matrix-widget-api"; +import React, { type ComponentProps } from "react"; +import { type IWidget } from "matrix-widget-api"; import classNames from "classnames"; -import { IApp, isAppWidget } from "../../../stores/WidgetStore"; -import BaseAvatar, { BaseAvatarType } from "./BaseAvatar"; +import { type IApp, isAppWidget } from "../../../stores/WidgetStore"; +import BaseAvatar, { type BaseAvatarType } from "./BaseAvatar"; import { mediaFromMxc } from "../../../customisations/Media"; interface IProps extends Omit, "name" | "url" | "urls"> { diff --git a/src/components/views/avatars/WithPresenceIndicator.tsx b/src/components/views/avatars/WithPresenceIndicator.tsx index 9d10f8dce6..4c4caecd69 100644 --- a/src/components/views/avatars/WithPresenceIndicator.tsx +++ b/src/components/views/avatars/WithPresenceIndicator.tsx @@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ReactNode, useEffect, useState } from "react"; -import { ClientEvent, Room, RoomMember, RoomStateEvent, UserEvent } from "matrix-js-sdk/src/matrix"; +import React, { type JSX, type ReactNode, useEffect, useState } from "react"; +import { ClientEvent, type Room, type RoomMember, RoomStateEvent, UserEvent } from "matrix-js-sdk/src/matrix"; import { Tooltip } from "@vector-im/compound-web"; import { isPresenceEnabled } from "../../../utils/presence"; @@ -26,7 +26,7 @@ interface Props { children: ReactNode; } -enum Presence { +export enum Presence { // Note: the names here are used in CSS class names Online = "ONLINE", Away = "AWAY", @@ -52,14 +52,14 @@ function getDmMember(room: Room): RoomMember | null { return otherUserId ? room.getMember(otherUserId) : null; } -export const useDmMember = (room: Room): RoomMember | null => { - const [dmMember, setDmMember] = useState(getDmMember(room)); +export const useDmMember = (room?: Room): RoomMember | null => { + const [dmMember, setDmMember] = useState(room ? getDmMember(room) : null); const updateDmMember = (): void => { - setDmMember(getDmMember(room)); + setDmMember(room ? getDmMember(room) : null); }; - useEventEmitter(room.currentState, RoomStateEvent.Members, updateDmMember); - useEventEmitter(room.client, ClientEvent.AccountData, updateDmMember); + useEventEmitter(room?.currentState, RoomStateEvent.Members, updateDmMember); + useEventEmitter(room?.client, ClientEvent.AccountData, updateDmMember); useEffect(updateDmMember, [room]); return dmMember; @@ -86,7 +86,7 @@ function getPresence(member: RoomMember | null): Presence | null { return null; } -const usePresence = (room: Room, member: RoomMember | null): Presence | null => { +export const usePresence = (room: Room, member: RoomMember | null): Presence | null => { const [presence, setPresence] = useState(getPresence(member)); const updatePresence = (): void => { setPresence(getPresence(member)); diff --git a/src/components/views/beacon/BeaconListItem.tsx b/src/components/views/beacon/BeaconListItem.tsx index 01a7f9364a..ceedbf05fd 100644 --- a/src/components/views/beacon/BeaconListItem.tsx +++ b/src/components/views/beacon/BeaconListItem.tsx @@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { HTMLProps, useContext } from "react"; -import { Beacon, BeaconEvent, LocationAssetType } from "matrix-js-sdk/src/matrix"; +import React, { type HTMLProps, useContext } from "react"; +import { type Beacon, BeaconEvent, LocationAssetType } from "matrix-js-sdk/src/matrix"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; diff --git a/src/components/views/beacon/BeaconMarker.tsx b/src/components/views/beacon/BeaconMarker.tsx index 01d74e72b1..262901233b 100644 --- a/src/components/views/beacon/BeaconMarker.tsx +++ b/src/components/views/beacon/BeaconMarker.tsx @@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ReactNode, useContext } from "react"; -import * as maplibregl from "maplibre-gl"; -import { Beacon, BeaconEvent, LocationAssetType } from "matrix-js-sdk/src/matrix"; +import React, { type ReactNode, useContext } from "react"; +import { type Beacon, BeaconEvent, LocationAssetType } from "matrix-js-sdk/src/matrix"; +import type * as maplibregl from "maplibre-gl"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import { SmartMarker } from "../location"; diff --git a/src/components/views/beacon/BeaconStatus.tsx b/src/components/views/beacon/BeaconStatus.tsx index 1415dc229c..0ed7ceb4b0 100644 --- a/src/components/views/beacon/BeaconStatus.tsx +++ b/src/components/views/beacon/BeaconStatus.tsx @@ -6,9 +6,9 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { HTMLProps } from "react"; +import React, { type HTMLProps } from "react"; import classNames from "classnames"; -import { Beacon } from "matrix-js-sdk/src/matrix"; +import { type Beacon } from "matrix-js-sdk/src/matrix"; import StyledLiveBeaconIcon from "./StyledLiveBeaconIcon"; import { _t } from "../../../languageHandler"; diff --git a/src/components/views/beacon/BeaconStatusTooltip.tsx b/src/components/views/beacon/BeaconStatusTooltip.tsx index 1dc1b05e61..eb8a76f1c1 100644 --- a/src/components/views/beacon/BeaconStatusTooltip.tsx +++ b/src/components/views/beacon/BeaconStatusTooltip.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { useContext } from "react"; -import { Beacon, LocationAssetType } from "matrix-js-sdk/src/matrix"; +import { type Beacon, LocationAssetType } from "matrix-js-sdk/src/matrix"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import BeaconStatus from "./BeaconStatus"; diff --git a/src/components/views/beacon/BeaconViewDialog.tsx b/src/components/views/beacon/BeaconViewDialog.tsx index 27f9f2e520..d779ebbf2e 100644 --- a/src/components/views/beacon/BeaconViewDialog.tsx +++ b/src/components/views/beacon/BeaconViewDialog.tsx @@ -7,9 +7,9 @@ Please see LICENSE files in the repository root for full details. */ import React, { useState, useEffect } from "react"; -import { MatrixClient, Beacon, Room } from "matrix-js-sdk/src/matrix"; -import * as maplibregl from "maplibre-gl"; +import { type MatrixClient, type Beacon, type Room } from "matrix-js-sdk/src/matrix"; +import type * as maplibregl from "maplibre-gl"; import { Icon as LiveLocationIcon } from "../../../../res/img/location/live-location.svg"; import { useLiveBeacons } from "../../../utils/beacon/useLiveBeacons"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; @@ -17,7 +17,7 @@ import BaseDialog from "../dialogs/BaseDialog"; import Map from "../location/Map"; import ZoomButtons from "../location/ZoomButtons"; import BeaconMarker from "./BeaconMarker"; -import { Bounds, getBeaconBounds } from "../../../utils/beacon/bounds"; +import { type Bounds, getBeaconBounds } from "../../../utils/beacon/bounds"; import { getGeoUri } from "../../../utils/beacon"; import { _t } from "../../../languageHandler"; import AccessibleButton from "../elements/AccessibleButton"; @@ -26,7 +26,7 @@ import DialogOwnBeaconStatus from "./DialogOwnBeaconStatus"; import BeaconStatusTooltip from "./BeaconStatusTooltip"; import MapFallback from "../location/MapFallback"; import { MapError } from "../location/MapError"; -import { LocationShareError } from "../../../utils/location"; +import { type LocationShareError } from "../../../utils/location"; export interface IProps { roomId: Room["roomId"]; diff --git a/src/components/views/beacon/DialogOwnBeaconStatus.tsx b/src/components/views/beacon/DialogOwnBeaconStatus.tsx index e6b35d9247..fff6b0db4b 100644 --- a/src/components/views/beacon/DialogOwnBeaconStatus.tsx +++ b/src/components/views/beacon/DialogOwnBeaconStatus.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { useContext } from "react"; -import { Room, Beacon, LocationAssetType } from "matrix-js-sdk/src/matrix"; +import { type Room, type Beacon, LocationAssetType } from "matrix-js-sdk/src/matrix"; import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../../stores/OwnBeaconStore"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; diff --git a/src/components/views/beacon/DialogSidebar.tsx b/src/components/views/beacon/DialogSidebar.tsx index 9fcca26dde..ee86e6bcf0 100644 --- a/src/components/views/beacon/DialogSidebar.tsx +++ b/src/components/views/beacon/DialogSidebar.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { Beacon } from "matrix-js-sdk/src/matrix"; +import { type Beacon } from "matrix-js-sdk/src/matrix"; import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close"; import { _t } from "../../../languageHandler"; diff --git a/src/components/views/beacon/LeftPanelLiveShareWarning.tsx b/src/components/views/beacon/LeftPanelLiveShareWarning.tsx index 0c3f26f91e..d5338cf28f 100644 --- a/src/components/views/beacon/LeftPanelLiveShareWarning.tsx +++ b/src/components/views/beacon/LeftPanelLiveShareWarning.tsx @@ -8,16 +8,16 @@ Please see LICENSE files in the repository root for full details. import classNames from "classnames"; import React, { useEffect } from "react"; -import { Beacon, BeaconIdentifier } from "matrix-js-sdk/src/matrix"; +import { type Beacon, type BeaconIdentifier } from "matrix-js-sdk/src/matrix"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import { _t } from "../../../languageHandler"; import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../../stores/OwnBeaconStore"; import { Icon as LiveLocationIcon } from "../../../../res/img/location/live-location.svg"; -import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { Action } from "../../../dispatcher/actions"; import dispatcher from "../../../dispatcher/dispatcher"; -import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; +import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; interface Props { isMinimized?: boolean; diff --git a/src/components/views/beacon/LiveTimeRemaining.tsx b/src/components/views/beacon/LiveTimeRemaining.tsx index c0730938ee..d4d7ca262e 100644 --- a/src/components/views/beacon/LiveTimeRemaining.tsx +++ b/src/components/views/beacon/LiveTimeRemaining.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { useCallback, useEffect, useState } from "react"; -import { BeaconEvent, Beacon } from "matrix-js-sdk/src/matrix"; +import { BeaconEvent, type Beacon } from "matrix-js-sdk/src/matrix"; import { formatDuration } from "../../../DateUtils"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; diff --git a/src/components/views/beacon/OwnBeaconStatus.tsx b/src/components/views/beacon/OwnBeaconStatus.tsx index 7d0f277786..8ece201cbe 100644 --- a/src/components/views/beacon/OwnBeaconStatus.tsx +++ b/src/components/views/beacon/OwnBeaconStatus.tsx @@ -6,15 +6,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Beacon } from "matrix-js-sdk/src/matrix"; -import React, { HTMLProps } from "react"; +import { type Beacon } from "matrix-js-sdk/src/matrix"; +import React, { type HTMLProps } from "react"; import { _t } from "../../../languageHandler"; import { useOwnLiveBeacons } from "../../../utils/beacon"; import { preventDefaultWrapper } from "../../../utils/NativeEventUtils"; import BeaconStatus from "./BeaconStatus"; import { BeaconDisplayStatus } from "./displayStatus"; -import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; +import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; interface Props { displayStatus: BeaconDisplayStatus; diff --git a/src/components/views/beacon/RoomCallBanner.tsx b/src/components/views/beacon/RoomCallBanner.tsx index a9f25e7e6c..e4f0dfa608 100644 --- a/src/components/views/beacon/RoomCallBanner.tsx +++ b/src/components/views/beacon/RoomCallBanner.tsx @@ -7,15 +7,15 @@ Please see LICENSE files in the repository root for full details. */ import React, { useCallback } from "react"; -import { Room } from "matrix-js-sdk/src/matrix"; +import { type Room } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { _t } from "../../../languageHandler"; -import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; +import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; import defaultDispatcher from "../../../dispatcher/dispatcher"; -import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { Action } from "../../../dispatcher/actions"; -import { ConnectionState, ElementCall } from "../../../models/Call"; +import { ConnectionState, type ElementCall } from "../../../models/Call"; import { useCall } from "../../../hooks/useCall"; import { useEventEmitterState } from "../../../hooks/useEventEmitter"; import { OwnBeaconStore, OwnBeaconStoreEvent } from "../../../stores/OwnBeaconStore"; diff --git a/src/components/views/beacon/ShareLatestLocation.tsx b/src/components/views/beacon/ShareLatestLocation.tsx index 5300a81900..b18cdff839 100644 --- a/src/components/views/beacon/ShareLatestLocation.tsx +++ b/src/components/views/beacon/ShareLatestLocation.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { useEffect, useState } from "react"; -import { ContentHelpers } from "matrix-js-sdk/src/matrix"; +import { type ContentHelpers } from "matrix-js-sdk/src/matrix"; import { Tooltip } from "@vector-im/compound-web"; import { Icon as ExternalLinkIcon } from "../../../../res/img/external-link.svg"; diff --git a/src/components/views/beacon/displayStatus.ts b/src/components/views/beacon/displayStatus.ts index e11b0018ba..15320c2fa5 100644 --- a/src/components/views/beacon/displayStatus.ts +++ b/src/components/views/beacon/displayStatus.ts @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { ContentHelpers } from "matrix-js-sdk/src/matrix"; +import { type ContentHelpers } from "matrix-js-sdk/src/matrix"; export enum BeaconDisplayStatus { Loading = "Loading", diff --git a/src/components/views/beacon/index.tsx b/src/components/views/beacon/index.tsx index 871e7cb07e..313e1145a9 100644 --- a/src/components/views/beacon/index.tsx +++ b/src/components/views/beacon/index.tsx @@ -8,7 +8,7 @@ Please see LICENSE files in the repository root for full details. // Exports beacon components which touch maplibre-gs wrapped in React Suspense to enable code splitting -import React, { ComponentProps, lazy, Suspense } from "react"; +import React, { type JSX, type ComponentProps, lazy, Suspense } from "react"; import Spinner from "../elements/Spinner"; diff --git a/src/components/views/beta/BetaCard.tsx b/src/components/views/beta/BetaCard.tsx index 93d69dffce..4421a9e50b 100644 --- a/src/components/views/beta/BetaCard.tsx +++ b/src/components/views/beta/BetaCard.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ReactNode, useState } from "react"; +import React, { type ReactNode, useState } from "react"; import { sleep } from "matrix-js-sdk/src/utils"; import { _t } from "../../../languageHandler"; @@ -20,7 +20,7 @@ import SettingsFlag from "../elements/SettingsFlag"; import { useFeatureEnabled } from "../../../hooks/useSettings"; import InlineSpinner from "../elements/InlineSpinner"; import { shouldShowFeedback } from "../../../utils/Feedback"; -import { FeatureSettingKey } from "../../../settings/Settings.tsx"; +import { type FeatureSettingKey } from "../../../settings/Settings.tsx"; // XXX: Keep this around for re-use in future Betas diff --git a/src/components/views/context_menus/DeveloperToolsOption.tsx b/src/components/views/context_menus/DeveloperToolsOption.tsx index fc77a2e284..53a5d7283d 100644 --- a/src/components/views/context_menus/DeveloperToolsOption.tsx +++ b/src/components/views/context_menus/DeveloperToolsOption.tsx @@ -25,7 +25,6 @@ export const DeveloperToolsOption: React.FC = ({ onFinished, roomId }) => Modal.createDialog( DevtoolsDialog, { - onFinished: () => {}, roomId: roomId, }, "mx_DevtoolsDialog_wrapper", diff --git a/src/components/views/context_menus/DeviceContextMenu.tsx b/src/components/views/context_menus/DeviceContextMenu.tsx index 5d71049fb4..b6646c05ec 100644 --- a/src/components/views/context_menus/DeviceContextMenu.tsx +++ b/src/components/views/context_menus/DeviceContextMenu.tsx @@ -10,8 +10,8 @@ import React, { useEffect, useState } from "react"; import MediaDeviceHandler, { MediaDeviceKindEnum } from "../../../MediaDeviceHandler"; import IconizedContextMenu, { IconizedContextMenuOptionList, IconizedContextMenuRadio } from "./IconizedContextMenu"; -import { IProps as IContextMenuProps } from "../../structures/ContextMenu"; -import { _t, _td, TranslationKey } from "../../../languageHandler"; +import { type IProps as IContextMenuProps } from "../../structures/ContextMenu"; +import { _t, _td, type TranslationKey } from "../../../languageHandler"; const SECTION_NAMES: Record = { [MediaDeviceKindEnum.AudioInput]: _td("voip|input_devices"), diff --git a/src/components/views/context_menus/DialpadContextMenu.tsx b/src/components/views/context_menus/DialpadContextMenu.tsx index c4d0d73786..004c86ff72 100644 --- a/src/components/views/context_menus/DialpadContextMenu.tsx +++ b/src/components/views/context_menus/DialpadContextMenu.tsx @@ -6,12 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import * as React from "react"; -import { createRef } from "react"; -import { MatrixCall } from "matrix-js-sdk/src/webrtc/call"; +import React, { createRef } from "react"; +import { type MatrixCall } from "matrix-js-sdk/src/webrtc/call"; -import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; -import ContextMenu, { IProps as IContextMenuProps } from "../../structures/ContextMenu"; +import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; +import ContextMenu, { type IProps as IContextMenuProps } from "../../structures/ContextMenu"; import Field from "../elements/Field"; import DialPad from "../voip/DialPad"; @@ -24,7 +23,7 @@ interface IState { } export default class DialpadContextMenu extends React.Component { - private numberEntryFieldRef: React.RefObject = createRef(); + private numberEntryFieldRef = createRef(); public constructor(props: IProps) { super(props); diff --git a/src/components/views/context_menus/IconizedContextMenu.tsx b/src/components/views/context_menus/IconizedContextMenu.tsx index 17a3805513..b6372a96c6 100644 --- a/src/components/views/context_menus/IconizedContextMenu.tsx +++ b/src/components/views/context_menus/IconizedContextMenu.tsx @@ -6,12 +6,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ReactNode } from "react"; +import React, { type JSX, type ReactNode } from "react"; import classNames from "classnames"; import ContextMenu, { ChevronFace, - IProps as IContextMenuProps, + type IProps as IContextMenuProps, MenuItem, MenuItemCheckbox, MenuItemRadio, diff --git a/src/components/views/context_menus/KebabContextMenu.tsx b/src/components/views/context_menus/KebabContextMenu.tsx index e65f0389ff..f6fc9972a0 100644 --- a/src/components/views/context_menus/KebabContextMenu.tsx +++ b/src/components/views/context_menus/KebabContextMenu.tsx @@ -9,8 +9,8 @@ Please see LICENSE files in the repository root for full details. import React from "react"; import ContextMenuIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal"; -import { ChevronFace, ContextMenuButton, MenuProps, useContextMenu } from "../../structures/ContextMenu"; -import { ButtonProps } from "../elements/AccessibleButton"; +import { ChevronFace, ContextMenuButton, type MenuProps, useContextMenu } from "../../structures/ContextMenu"; +import { type ButtonProps } from "../elements/AccessibleButton"; import IconizedContextMenu, { IconizedContextMenuOptionList } from "./IconizedContextMenu"; const contextMenuBelow = (elementRect: DOMRect): MenuProps => { diff --git a/src/components/views/context_menus/LegacyCallContextMenu.tsx b/src/components/views/context_menus/LegacyCallContextMenu.tsx index bc3deab7a1..3427ed183b 100644 --- a/src/components/views/context_menus/LegacyCallContextMenu.tsx +++ b/src/components/views/context_menus/LegacyCallContextMenu.tsx @@ -6,10 +6,10 @@ Please see LICENSE files in the repository root for full details. */ import React from "react"; -import { MatrixCall } from "matrix-js-sdk/src/webrtc/call"; +import { type MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { _t } from "../../../languageHandler"; -import ContextMenu, { IProps as IContextMenuProps, MenuItem } from "../../structures/ContextMenu"; +import ContextMenu, { type IProps as IContextMenuProps, MenuItem } from "../../structures/ContextMenu"; import LegacyCallHandler from "../../../LegacyCallHandler"; interface IProps extends IContextMenuProps { diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 0af3604fca..76f6b31989 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -8,15 +8,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { createRef, useContext } from "react"; +import React, { type JSX, createRef, useContext } from "react"; import { EventStatus, - MatrixEvent, + type MatrixEvent, MatrixEventEvent, RoomMemberEvent, EventType, RelationType, - Relations, + type Relations, Thread, M_POLL_START, } from "matrix-js-sdk/src/matrix"; @@ -31,10 +31,10 @@ import { isUrlPermitted } from "../../../HtmlUtils"; import { canEditContent, editEvent, isContentActionable } from "../../../utils/EventUtils"; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu"; import { Action } from "../../../dispatcher/actions"; -import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; -import { ButtonEvent } from "../elements/AccessibleButton"; +import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; +import { type ButtonEvent } from "../elements/AccessibleButton"; import { copyPlaintext, getSelectedText } from "../../../utils/strings"; -import ContextMenu, { toRightOf, MenuProps } from "../../structures/ContextMenu"; +import ContextMenu, { toRightOf, type MenuProps } from "../../structures/ContextMenu"; import ReactionPicker from "../emojipicker/ReactionPicker"; import ViewSource from "../../structures/ViewSource"; import { createRedactEventDialog } from "../dialogs/ConfirmRedactDialog"; @@ -42,14 +42,14 @@ import { ShareDialog } from "../dialogs/ShareDialog"; import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import EndPollDialog from "../dialogs/EndPollDialog"; import { isPollEnded } from "../messages/MPollBody"; -import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import { GetRelationsForEvent, IEventTileOps } from "../rooms/EventTile"; -import { OpenForwardDialogPayload } from "../../../dispatcher/payloads/OpenForwardDialogPayload"; -import { OpenReportEventDialogPayload } from "../../../dispatcher/payloads/OpenReportEventDialogPayload"; +import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { type GetRelationsForEvent, type IEventTileOps } from "../rooms/EventTile"; +import { type OpenForwardDialogPayload } from "../../../dispatcher/payloads/OpenForwardDialogPayload"; +import { type OpenReportEventDialogPayload } from "../../../dispatcher/payloads/OpenReportEventDialogPayload"; import { createMapSiteLinkFromEvent } from "../../../utils/location"; import { getForwardableEvent } from "../../../events/forward/getForwardableEvent"; import { getShareableLocationEvent } from "../../../events/location/getShareableLocationEvent"; -import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; +import { type ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { CardContext } from "../right_panel/context"; import PinningUtils from "../../../utils/PinningUtils"; import PosthogTrackers from "../../../PosthogTrackers.ts"; @@ -130,8 +130,8 @@ export default class MessageContextMenu extends React.Component private reactButtonRef = createRef(); // XXX Ref to a functional component - public constructor(props: IProps, context: React.ContextType) { - super(props, context); + public constructor(props: IProps) { + super(props); this.state = { canRedact: false, diff --git a/src/components/views/context_menus/RoomGeneralContextMenu.tsx b/src/components/views/context_menus/RoomGeneralContextMenu.tsx index c54aa1e465..7c21d09853 100644 --- a/src/components/views/context_menus/RoomGeneralContextMenu.tsx +++ b/src/components/views/context_menus/RoomGeneralContextMenu.tsx @@ -7,8 +7,8 @@ Please see LICENSE files in the repository root for full details. */ import { logger } from "matrix-js-sdk/src/logger"; -import { Room } from "matrix-js-sdk/src/matrix"; -import React, { useContext } from "react"; +import { type Room } from "matrix-js-sdk/src/matrix"; +import React, { type JSX, useContext } from "react"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import RoomListActions from "../../../actions/RoomListActions"; @@ -19,17 +19,17 @@ import { useUnreadNotifications } from "../../../hooks/useUnreadNotifications"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { _t } from "../../../languageHandler"; import { NotificationLevel } from "../../../stores/notifications/NotificationLevel"; -import { DefaultTagID, TagID } from "../../../stores/room-list/models"; +import { DefaultTagID, type TagID } from "../../../stores/room-list/models"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore"; import DMRoomMap from "../../../utils/DMRoomMap"; import { clearRoomNotification, setMarkedUnreadState } from "../../../utils/notifications"; -import { IProps as IContextMenuProps } from "../../structures/ContextMenu"; +import { type IProps as IContextMenuProps } from "../../structures/ContextMenu"; import IconizedContextMenu, { IconizedContextMenuCheckbox, IconizedContextMenuOption, IconizedContextMenuOptionList, } from "../context_menus/IconizedContextMenu"; -import { ButtonEvent } from "../elements/AccessibleButton"; +import { type ButtonEvent } from "../elements/AccessibleButton"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../settings/UIFeature"; import { DeveloperToolsOption } from "./DeveloperToolsOption"; diff --git a/src/components/views/context_menus/RoomNotificationContextMenu.tsx b/src/components/views/context_menus/RoomNotificationContextMenu.tsx index ea63880762..9844f27695 100644 --- a/src/components/views/context_menus/RoomNotificationContextMenu.tsx +++ b/src/components/views/context_menus/RoomNotificationContextMenu.tsx @@ -6,20 +6,20 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { Room } from "matrix-js-sdk/src/matrix"; -import React from "react"; +import { type Room } from "matrix-js-sdk/src/matrix"; +import React, { type JSX } from "react"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { useNotificationState } from "../../../hooks/useRoomNotificationState"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { _t } from "../../../languageHandler"; import { RoomNotifState } from "../../../RoomNotifs"; -import { IProps as IContextMenuProps } from "../../structures/ContextMenu"; +import { type IProps as IContextMenuProps } from "../../structures/ContextMenu"; import IconizedContextMenu, { IconizedContextMenuOptionList, IconizedContextMenuRadio, } from "../context_menus/IconizedContextMenu"; -import { ButtonEvent } from "../elements/AccessibleButton"; +import { type ButtonEvent } from "../elements/AccessibleButton"; interface IProps extends IContextMenuProps { room: Room; diff --git a/src/components/views/context_menus/SpaceContextMenu.tsx b/src/components/views/context_menus/SpaceContextMenu.tsx index 11a1364d6d..eab9c1d011 100644 --- a/src/components/views/context_menus/SpaceContextMenu.tsx +++ b/src/components/views/context_menus/SpaceContextMenu.tsx @@ -6,10 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { useContext } from "react"; -import { Room, EventType, RoomType } from "matrix-js-sdk/src/matrix"; +import React, { type JSX, useContext } from "react"; +import { type Room, EventType, RoomType } from "matrix-js-sdk/src/matrix"; -import { IProps as IContextMenuProps } from "../../structures/ContextMenu"; +import { type IProps as IContextMenuProps } from "../../structures/ContextMenu"; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu"; import { _t } from "../../../languageHandler"; import { @@ -22,7 +22,7 @@ import { } from "../../../utils/space"; import { leaveSpace } from "../../../utils/leave-behaviour"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; -import { ButtonEvent } from "../elements/AccessibleButton"; +import { type ButtonEvent } from "../elements/AccessibleButton"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import { BetaPill } from "../beta/BetaCard"; import SettingsStore from "../../../settings/SettingsStore"; @@ -31,7 +31,7 @@ import { Action } from "../../../dispatcher/actions"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { UIComponent } from "../../../settings/UIFeature"; import PosthogTrackers from "../../../PosthogTrackers"; -import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; interface IProps extends IContextMenuProps { space?: Room; diff --git a/src/components/views/context_menus/ThreadListContextMenu.tsx b/src/components/views/context_menus/ThreadListContextMenu.tsx index eea9815954..ef32d62282 100644 --- a/src/components/views/context_menus/ThreadListContextMenu.tsx +++ b/src/components/views/context_menus/ThreadListContextMenu.tsx @@ -7,19 +7,19 @@ Please see LICENSE files in the repository root for full details. */ import React, { useCallback, useEffect } from "react"; -import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; -import { ButtonEvent } from "../elements/AccessibleButton"; +import { type ButtonEvent } from "../elements/AccessibleButton"; import dis from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; -import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; +import { type RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { copyPlaintext } from "../../../utils/strings"; -import { ChevronFace, ContextMenuTooltipButton, MenuProps, useContextMenu } from "../../structures/ContextMenu"; +import { ChevronFace, ContextMenuTooltipButton, type MenuProps, useContextMenu } from "../../structures/ContextMenu"; import { _t } from "../../../languageHandler"; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu"; import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; export interface ThreadListContextMenuProps { mxEvent: MatrixEvent; diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx index 894e8f8c7d..5b03d54e17 100644 --- a/src/components/views/context_menus/WidgetContextMenu.tsx +++ b/src/components/views/context_menus/WidgetContextMenu.tsx @@ -6,11 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ComponentProps, useContext } from "react"; -import { ClientWidgetApi, IWidget, MatrixCapabilities } from "matrix-widget-api"; +import React, { type JSX, type ComponentProps, useContext } from "react"; +import { type ClientWidgetApi, type IWidget, MatrixCapabilities } from "matrix-widget-api"; import { logger } from "matrix-js-sdk/src/logger"; -import { ApprovalOpts, WidgetLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle"; -import { MatrixClient, Room } from "matrix-js-sdk/src/matrix"; +import { type ApprovalOpts, WidgetLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle"; +import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix"; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu"; import { ChevronFace } from "../../structures/ContextMenu"; @@ -187,14 +187,15 @@ export const WidgetContextMenu: React.FC = ({ onDeleteClick(); } else if (roomId) { // Show delete confirmation dialog - Modal.createDialog(QuestionDialog, { + const { finished } = Modal.createDialog(QuestionDialog, { title: _t("widget|context_menu|delete"), description: _t("widget|context_menu|delete_warning"), button: _t("widget|context_menu|delete"), - onFinished: (confirmed) => { - if (!confirmed) return; - WidgetUtils.setRoomWidget(cli, roomId, app.id); - }, + }); + + finished.then(([confirmed]) => { + if (!confirmed) return; + WidgetUtils.setRoomWidget(cli, roomId, app.id); }); } diff --git a/src/components/views/dialogs/AddExistingSubspaceDialog.tsx b/src/components/views/dialogs/AddExistingSubspaceDialog.tsx index d7311244b7..09a828194a 100644 --- a/src/components/views/dialogs/AddExistingSubspaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingSubspaceDialog.tsx @@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { useState } from "react"; -import { Room } from "matrix-js-sdk/src/matrix"; +import { type Room } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../languageHandler"; import BaseDialog from "./BaseDialog"; diff --git a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx index 3cc62f4155..c247c3aea9 100644 --- a/src/components/views/dialogs/AddExistingToSpaceDialog.tsx +++ b/src/components/views/dialogs/AddExistingToSpaceDialog.tsx @@ -1,27 +1,27 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024,2025 New Vector Ltd. Copyright 2021 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial Please see LICENSE files in the repository root for full details. */ -import React, { ReactElement, ReactNode, useContext, useMemo, useRef, useState } from "react"; +import React, { type ReactElement, type ReactNode, useContext, useId, useMemo, useRef, useState } from "react"; import classNames from "classnames"; -import { Room, EventType } from "matrix-js-sdk/src/matrix"; +import { type Room, EventType } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { sleep } from "matrix-js-sdk/src/utils"; import { logger } from "matrix-js-sdk/src/logger"; import { ErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { _t, _td, TranslationKey } from "../../../languageHandler"; +import { _t, _td, type TranslationKey } from "../../../languageHandler"; import BaseDialog from "./BaseDialog"; import Dropdown from "../elements/Dropdown"; import SearchBox from "../../structures/SearchBox"; import SpaceStore from "../../../stores/spaces/SpaceStore"; import RoomAvatar from "../avatars/RoomAvatar"; import { getDisplayAliasForRoom } from "../../../Rooms"; -import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; +import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; import AutoHideScrollbar from "../../structures/AutoHideScrollbar"; import DMRoomMap from "../../../utils/DMRoomMap"; import { calculateRoomVia } from "../../../utils/permalinks/Permalinks"; @@ -34,7 +34,7 @@ import QueryMatcher from "../../../autocomplete/QueryMatcher"; import LazyRenderList from "../elements/LazyRenderList"; import { useSettingValue } from "../../../hooks/useSettings"; import { filterBoolean } from "../../../utils/arrays"; -import { NonEmptyArray } from "../../../@types/common"; +import { type NonEmptyArray } from "../../../@types/common"; // These values match CSS const ROW_HEIGHT = 32 + 12; @@ -53,8 +53,9 @@ export const Entry: React.FC<{ checked: boolean; onChange?(value: boolean): void; }> = ({ room, checked, onChange }) => { + const id = useId(); return ( -
  • {room?.isSpaceRoom() ? ( ) : ( @@ -62,11 +63,12 @@ export const Entry: React.FC<{ )} {room.name} onChange(e.currentTarget.checked) : undefined} checked={checked} disabled={!onChange} /> - +
  • ); }; @@ -357,6 +359,7 @@ const defaultRendererFactory =

    {_t(title)}

    = ({ export const showDialog = ( props: Omit, "cookiePolicyUrl" | "analyticsOwner">, -): void => { +): IHandle => { const privacyPolicyUrl = getPolicyUrl(); const analyticsOwner = SdkConfig.get("analytics_owner") ?? SdkConfig.get("brand"); - Modal.createDialog( + return Modal.createDialog( AnalyticsLearnMoreDialog, { privacyPolicyUrl, diff --git a/src/components/views/dialogs/AskInviteAnywayDialog.tsx b/src/components/views/dialogs/AskInviteAnywayDialog.tsx index b18d70df11..ddc4c04d4a 100644 --- a/src/components/views/dialogs/AskInviteAnywayDialog.tsx +++ b/src/components/views/dialogs/AskInviteAnywayDialog.tsx @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { useCallback } from "react"; +import React, { type JSX, useCallback } from "react"; import { _t } from "../../../languageHandler"; import SettingsStore from "../../../settings/SettingsStore"; diff --git a/src/components/views/dialogs/BaseDialog.tsx b/src/components/views/dialogs/BaseDialog.tsx index 39afb2e621..bf23919771 100644 --- a/src/components/views/dialogs/BaseDialog.tsx +++ b/src/components/views/dialogs/BaseDialog.tsx @@ -8,17 +8,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React from "react"; +import React, { type JSX } from "react"; import FocusLock from "react-focus-lock"; import classNames from "classnames"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import AccessibleButton from "../elements/AccessibleButton"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { _t } from "../../../languageHandler"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import Heading from "../typography/Heading"; -import { PosthogScreenTracker, ScreenName } from "../../../PosthogTrackers"; +import { PosthogScreenTracker, type ScreenName } from "../../../PosthogTrackers"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; diff --git a/src/components/views/dialogs/BetaFeedbackDialog.tsx b/src/components/views/dialogs/BetaFeedbackDialog.tsx index 2c13ae0006..081f8beaca 100644 --- a/src/components/views/dialogs/BetaFeedbackDialog.tsx +++ b/src/components/views/dialogs/BetaFeedbackDialog.tsx @@ -15,7 +15,7 @@ import defaultDispatcher from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; import { UserTab } from "./UserTab"; import GenericFeatureFeedbackDialog from "./GenericFeatureFeedbackDialog"; -import { SettingKey } from "../../../settings/Settings.tsx"; +import { type SettingKey } from "../../../settings/Settings.tsx"; // XXX: Keep this around for re-use in future Betas diff --git a/src/components/views/dialogs/BugReportDialog.tsx b/src/components/views/dialogs/BugReportDialog.tsx index 013f9ecb06..83e6da8dfe 100644 --- a/src/components/views/dialogs/BugReportDialog.tsx +++ b/src/components/views/dialogs/BugReportDialog.tsx @@ -9,12 +9,13 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React from "react"; +import React, { type JSX, type ReactNode } from "react"; +import { Link } from "@vector-im/compound-web"; import SdkConfig from "../../../SdkConfig"; import Modal from "../../../Modal"; import { _t } from "../../../languageHandler"; -import sendBugReport, { downloadBugReport } from "../../../rageshake/submit-rageshake"; +import sendBugReport, { downloadBugReport, RageshakeError } from "../../../rageshake/submit-rageshake"; import AccessibleButton from "../elements/AccessibleButton"; import QuestionDialog from "./QuestionDialog"; import BaseDialog from "./BaseDialog"; @@ -26,7 +27,7 @@ import defaultDispatcher from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; import { getBrowserSupport } from "../../../SupportedBrowser"; -interface IProps { +export interface BugReportDialogProps { onFinished: (success: boolean) => void; initialText?: string; label?: string; @@ -36,7 +37,7 @@ interface IProps { interface IState { sendLogs: boolean; busy: boolean; - err: string | null; + err: ReactNode | null; issueUrl: string; text: string; progress: string | null; @@ -44,11 +45,11 @@ interface IState { downloadProgress: string | null; } -export default class BugReportDialog extends React.Component { +export default class BugReportDialog extends React.Component { private unmounted: boolean; - private issueRef: React.RefObject; + private issueRef: React.RefObject; - public constructor(props: IProps) { + public constructor(props: BugReportDialogProps) { super(props); this.state = { @@ -89,6 +90,42 @@ export default class BugReportDialog extends React.Component { this.props.onFinished(false); }; + private getErrorText(error: Error | RageshakeError): ReactNode { + if (error instanceof RageshakeError) { + let errorText; + switch (error.errorcode) { + case "DISALLOWED_APP": + errorText = _t("bug_reporting|failed_send_logs_causes|disallowed_app"); + break; + case "REJECTED_BAD_VERSION": + errorText = _t("bug_reporting|failed_send_logs_causes|rejected_version"); + break; + case "REJECTED_UNEXPECTED_RECOVERY_KEY": + errorText = _t("bug_reporting|failed_send_logs_causes|rejected_recovery_key"); + break; + default: + if (error.errorcode?.startsWith("REJECTED")) { + errorText = _t("bug_reporting|failed_send_logs_causes|rejected_generic"); + } else { + errorText = _t("bug_reporting|failed_send_logs_causes|server_unknown_error"); + } + break; + } + return ( + <> +

    {errorText}

    + {error.policyURL && ( + + {_t("action|learn_more")} + + )} + + ); + } else { + return

    {_t("bug_reporting|failed_send_logs_causes|unknown_error")}

    ; + } + } + private onSubmit = (): void => { if ((!this.state.text || !this.state.text.trim()) && (!this.state.issueUrl || !this.state.issueUrl.trim())) { this.setState({ @@ -126,7 +163,7 @@ export default class BugReportDialog extends React.Component { this.setState({ busy: false, progress: null, - err: _t("bug_reporting|failed_send_logs") + `${err.message}`, + err: this.getErrorText(err), }); } }, @@ -155,7 +192,7 @@ export default class BugReportDialog extends React.Component { this.setState({ downloadBusy: false, downloadProgress: - _t("bug_reporting|failed_send_logs") + `${err instanceof Error ? err.message : ""}`, + _t("bug_reporting|failed_download_logs") + `${err instanceof Error ? err.message : ""}`, }); } } diff --git a/src/components/views/dialogs/BulkRedactDialog.tsx b/src/components/views/dialogs/BulkRedactDialog.tsx index d766c6c973..1b8bf9b07e 100644 --- a/src/components/views/dialogs/BulkRedactDialog.tsx +++ b/src/components/views/dialogs/BulkRedactDialog.tsx @@ -1,5 +1,5 @@ /* -Copyright 2024 New Vector Ltd. +Copyright 2024,2025 New Vector Ltd. Copyright 2021 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial @@ -8,7 +8,14 @@ Please see LICENSE files in the repository root for full details. import React, { useState } from "react"; import { logger } from "matrix-js-sdk/src/logger"; -import { MatrixClient, RoomMember, Room, MatrixEvent, EventTimeline, EventType } from "matrix-js-sdk/src/matrix"; +import { + type MatrixClient, + type RoomMember, + type Room, + type MatrixEvent, + EventTimeline, + EventType, +} from "matrix-js-sdk/src/matrix"; import { _t } from "../../../languageHandler"; import dis from "../../../dispatcher/dispatcher"; @@ -106,12 +113,13 @@ const BulkRedactDialog: React.FC = (props) => {

    {_t("user_info|redact|confirm_description_1", { count, user })}

    {_t("user_info|redact|confirm_description_2")}

    - setKeepStateEvents(e.target.checked)}> + setKeepStateEvents(e.target.checked)} + > {_t("user_info|redact|confirm_keep_state_label")} -
    - {_t("user_info|redact|confirm_keep_state_explainer")} -
    void; +} + +/** + * Ask the user whether they really want to dismiss the toast about key storage. + * + * Launched from the {@link SetupEncryptionToast} in mode `TURN_ON_KEY_STORAGE`, + * when the user clicks "Dismiss". The caller handles any action via the + * `onFinished` prop which takes a boolean that is true if the user clicked + * "Yes, dismiss". + */ +export default class ConfirmKeyStorageOffDialog extends React.Component { + public constructor(props: Props) { + super(props); + } + + private onGoToSettingsClick = (): void => { + // Open Settings at the Encryption tab + const payload: OpenToTabPayload = { + action: Action.ViewUserSettings, + initialTabId: UserTab.Encryption, + }; + defaultDispatcher.dispatch(payload); + this.props.onFinished(false); + }; + + private onDismissClick = (): void => { + this.props.onFinished(true); + }; + + public render(): React.ReactNode { + return ( + + {_t("settings|encryption|confirm_key_storage_off_description", undefined, { + a: (sub) => ( + <> +
    + + {sub} + + + ), + })} + + + + +
    + ); + } +} diff --git a/src/components/views/dialogs/ConfirmRedactDialog.tsx b/src/components/views/dialogs/ConfirmRedactDialog.tsx index 7733d83585..17bf75ccad 100644 --- a/src/components/views/dialogs/ConfirmRedactDialog.tsx +++ b/src/components/views/dialogs/ConfirmRedactDialog.tsx @@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { IRedactOpts, MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { type IRedactOpts, type MatrixEvent } from "matrix-js-sdk/src/matrix"; import React from "react"; import { _t } from "../../../languageHandler"; @@ -58,37 +58,32 @@ export function createRedactEventDialog({ const roomId = mxEvent.getRoomId(); if (!roomId) throw new Error(`cannot redact event ${mxEvent.getId()} without room ID`); - Modal.createDialog( - ConfirmRedactDialog, - { - event: mxEvent, - onFinished: async (proceed, reason): Promise => { - if (!proceed) return; + const { finished } = Modal.createDialog(ConfirmRedactDialog, { event: mxEvent }, "mx_Dialog_confirmredact"); - const cli = MatrixClientPeg.safeGet(); - const withRelTypes: Pick = {}; + finished.then(async ([proceed, reason]) => { + if (!proceed) return; - try { - onCloseDialog?.(); - await cli.redactEvent(roomId, eventId, undefined, { - ...(reason ? { reason } : {}), - ...withRelTypes, - }); - } catch (e: any) { - const code = e.errcode || e.statusCode; - // only show the dialog if failing for something other than a network error - // (e.g. no errcode or statusCode) as in that case the redactions end up in the - // detached queue and we show the room status bar to allow retry - if (typeof code !== "undefined") { - // display error message stating you couldn't delete this. - Modal.createDialog(ErrorDialog, { - title: _t("common|error"), - description: _t("redact|error", { code }), - }); - } - } - }, - }, - "mx_Dialog_confirmredact", - ); + const cli = MatrixClientPeg.safeGet(); + const withRelTypes: Pick = {}; + + try { + onCloseDialog?.(); + await cli.redactEvent(roomId, eventId, undefined, { + ...(reason ? { reason } : {}), + ...withRelTypes, + }); + } catch (e: any) { + const code = e.errcode || e.statusCode; + // only show the dialog if failing for something other than a network error + // (e.g. no errcode or statusCode) as in that case the redactions end up in the + // detached queue and we show the room status bar to allow retry + if (typeof code !== "undefined") { + // display error message stating you couldn't delete this. + Modal.createDialog(ErrorDialog, { + title: _t("common|error"), + description: _t("redact|error", { code }), + }); + } + } + }); } diff --git a/src/components/views/dialogs/ConfirmSpaceUserActionDialog.tsx b/src/components/views/dialogs/ConfirmSpaceUserActionDialog.tsx index 9c21b469e4..3851460bdd 100644 --- a/src/components/views/dialogs/ConfirmSpaceUserActionDialog.tsx +++ b/src/components/views/dialogs/ConfirmSpaceUserActionDialog.tsx @@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ComponentProps, useMemo, useState } from "react"; -import { Room } from "matrix-js-sdk/src/matrix"; +import React, { type JSX, type ComponentProps, useMemo, useState } from "react"; +import { type Room } from "matrix-js-sdk/src/matrix"; import ConfirmUserActionDialog from "./ConfirmUserActionDialog"; import SpaceStore from "../../../stores/spaces/SpaceStore"; diff --git a/src/components/views/dialogs/ConfirmUserActionDialog.tsx b/src/components/views/dialogs/ConfirmUserActionDialog.tsx index f79601f855..3fc7b25aec 100644 --- a/src/components/views/dialogs/ConfirmUserActionDialog.tsx +++ b/src/components/views/dialogs/ConfirmUserActionDialog.tsx @@ -6,8 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ChangeEvent, FormEvent, ReactNode } from "react"; -import { RoomMember } from "matrix-js-sdk/src/matrix"; +import React, { type ChangeEvent, type FormEvent, type ReactNode } from "react"; +import { type RoomMember } from "matrix-js-sdk/src/matrix"; import classNames from "classnames"; import { _t } from "../../../languageHandler"; diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx index d77ec14578..3ff42cde22 100644 --- a/src/components/views/dialogs/CreateRoomDialog.tsx +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -7,14 +7,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { ChangeEvent, createRef, KeyboardEvent, SyntheticEvent } from "react"; -import { Room, RoomType, JoinRule, Preset, Visibility } from "matrix-js-sdk/src/matrix"; +import React, { type JSX, type ChangeEvent, createRef, type KeyboardEvent, type SyntheticEvent } from "react"; +import { type Room, RoomType, JoinRule, Preset, Visibility } from "matrix-js-sdk/src/matrix"; import SdkConfig from "../../../SdkConfig"; -import withValidation, { IFieldState, IValidationResult } from "../elements/Validation"; +import withValidation, { type IFieldState, type IValidationResult } from "../elements/Validation"; import { _t } from "../../../languageHandler"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import { checkUserIsAllowedToChangeEncryption, IOpts } from "../../../createRoom"; +import { checkUserIsAllowedToChangeEncryption, type IOpts } from "../../../createRoom"; import Field from "../elements/Field"; import RoomAliasField from "../elements/RoomAliasField"; import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; diff --git a/src/components/views/dialogs/CreateSubspaceDialog.tsx b/src/components/views/dialogs/CreateSubspaceDialog.tsx index 6af128ef64..290c606731 100644 --- a/src/components/views/dialogs/CreateSubspaceDialog.tsx +++ b/src/components/views/dialogs/CreateSubspaceDialog.tsx @@ -6,17 +6,17 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { useRef, useState } from "react"; -import { Room, JoinRule } from "matrix-js-sdk/src/matrix"; +import React, { type JSX, useRef, useState } from "react"; +import { type Room, JoinRule } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { _t } from "../../../languageHandler"; import BaseDialog from "./BaseDialog"; -import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton"; +import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { BetaPill } from "../beta/BetaCard"; -import Field from "../elements/Field"; -import RoomAliasField from "../elements/RoomAliasField"; +import type Field from "../elements/Field"; +import type RoomAliasField from "../elements/RoomAliasField"; import { createSpace, SpaceCreateForm } from "../spaces/SpaceCreateMenu"; import { SubspaceSelector } from "./AddExistingToSpaceDialog"; import JoinRuleDropdown from "../elements/JoinRuleDropdown"; diff --git a/src/components/views/dialogs/DeactivateAccountDialog.tsx b/src/components/views/dialogs/DeactivateAccountDialog.tsx index 52140b2967..0622cb25eb 100644 --- a/src/components/views/dialogs/DeactivateAccountDialog.tsx +++ b/src/components/views/dialogs/DeactivateAccountDialog.tsx @@ -7,15 +7,15 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React from "react"; -import { AuthType, IAuthData } from "matrix-js-sdk/src/interactive-auth"; +import React, { type JSX } from "react"; +import { type AuthType, type IAuthData } from "matrix-js-sdk/src/interactive-auth"; import { logger } from "matrix-js-sdk/src/logger"; -import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { _t } from "../../../languageHandler"; -import InteractiveAuth, { ERROR_USER_CANCELLED, InteractiveAuthCallback } from "../../structures/InteractiveAuth"; -import { ContinueKind, SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents"; +import InteractiveAuth, { ERROR_USER_CANCELLED, type InteractiveAuthCallback } from "../../structures/InteractiveAuth"; +import { type ContinueKind, SSOAuthEntry } from "../auth/InteractiveAuthEntryComponents"; import StyledCheckbox from "../elements/StyledCheckbox"; import BaseDialog from "./BaseDialog"; import defaultDispatcher from "../../../dispatcher/dispatcher"; diff --git a/src/components/views/dialogs/DeclineAndBlockInviteDialog.tsx b/src/components/views/dialogs/DeclineAndBlockInviteDialog.tsx new file mode 100644 index 0000000000..a10dfbe42b --- /dev/null +++ b/src/components/views/dialogs/DeclineAndBlockInviteDialog.tsx @@ -0,0 +1,82 @@ +/* +Copyright 2025 New Vector Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { type ChangeEventHandler, useCallback, useState } from "react"; +import { Field, Label, Root } from "@vector-im/compound-web"; + +import { _t } from "../../../languageHandler"; +import BaseDialog from "./BaseDialog"; +import DialogButtons from "../elements/DialogButtons"; +import LabelledToggleSwitch from "../elements/LabelledToggleSwitch"; + +interface IProps { + onFinished: (shouldReject: boolean, ignoreUser: boolean, reportRoom: false | string) => void; + roomName: string; +} + +export const DeclineAndBlockInviteDialog: React.FunctionComponent = ({ onFinished, roomName }) => { + const [shouldReport, setShouldReport] = useState(false); + const [ignoreUser, setIgnoreUser] = useState(false); + + const [reportReason, setReportReason] = useState(""); + const reportReasonChanged = useCallback>( + (e) => setReportReason(e.target.value), + [setReportReason], + ); + + const onCancel = useCallback(() => onFinished(false, false, false), [onFinished]); + const onOk = useCallback( + () => onFinished(true, ignoreUser, shouldReport ? reportReason : false), + [onFinished, ignoreUser, shouldReport, reportReason], + ); + + return ( + + +

    {_t("decline_invitation_dialog|confirm", { roomName })}

    + + + + + +
    +
    + + + + +
    + +
    +
    +
    +
    +`; diff --git a/test/unit-tests/components/views/dialogs/__snapshots__/ConfirmUserActionDialog-test.tsx.snap b/test/unit-tests/components/views/dialogs/__snapshots__/ConfirmUserActionDialog-test.tsx.snap index bf0fc31443..d48b62c57d 100644 --- a/test/unit-tests/components/views/dialogs/__snapshots__/ConfirmUserActionDialog-test.tsx.snap +++ b/test/unit-tests/components/views/dialogs/__snapshots__/ConfirmUserActionDialog-test.tsx.snap @@ -35,7 +35,7 @@ exports[`ConfirmUserActionDialog renders 1`] = ` class="mx_ConfirmUserActionDialog_avatar" > Server info +

    @@ -185,6 +190,31 @@ exports[`DevtoolsDialog renders the devtools dialog 1`] = ` />