Replace usage of forwardRef with React 19 ref prop (#29803)

* Replace usage of `forwardRef` with React 19 ref prop

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Add lint rule

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
Michael Telatynski
2025-04-24 13:31:37 +01:00
committed by GitHub
parent 5e7b58a722
commit 22d5c00174
48 changed files with 989 additions and 963 deletions

View File

@@ -30,6 +30,10 @@ module.exports = {
["window.innerHeight", "window.innerWidth", "window.visualViewport"], ["window.innerHeight", "window.innerWidth", "window.visualViewport"],
"Use UIStore to access window dimensions instead.", "Use UIStore to access window dimensions instead.",
), ),
...buildRestrictedPropertiesOptions(
["React.forwardRef", "*.forwardRef", "forwardRef"],
"Use ref props instead.",
),
...buildRestrictedPropertiesOptions( ...buildRestrictedPropertiesOptions(
["*.mxcUrlToHttp", "*.getHttpUriForMxc"], ["*.mxcUrlToHttp", "*.getHttpUriForMxc"],
"Use Media helper instead to centralise access for customisation.", "Use Media helper instead to centralise access for customisation.",
@@ -55,6 +59,11 @@ module.exports = {
"error", "error",
{ {
paths: [ paths: [
{
name: "react",
importNames: ["forwardRef"],
message: "Use ref props instead.",
},
{ {
name: "@testing-library/react", name: "@testing-library/react",
message: "Please use jest-matrix-react instead", message: "Please use jest-matrix-react instead",

View File

@@ -6,16 +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. Please see LICENSE files in the repository root for full details.
*/ */
import { type PropsWithChildren } from "react"; import { type ComponentType } from "react";
import type React from "react";
declare module "react" { declare module "react" {
// Fix forwardRef types for Generic components - https://stackoverflow.com/a/58473012
function forwardRef<T, P extends object>(
render: (props: PropsWithChildren<P>, ref: React.ForwardedRef<T>) => React.ReactElement | null,
): (props: P & React.RefAttributes<T>) => React.ReactElement | null;
// Fix lazy types - https://stackoverflow.com/a/71017028 // Fix lazy types - https://stackoverflow.com/a/71017028
function lazy<T extends ComponentType<any>>(factory: () => Promise<{ default: T }>): T; function lazy<T extends ComponentType<any>>(factory: () => Promise<{ default: T }>): T;

View File

@@ -6,18 +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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { forwardRef } from "react"; import React, { type Ref, type JSX } from "react";
import { RovingTabIndexProvider } from "./RovingTabIndex"; import { RovingTabIndexProvider } from "./RovingTabIndex";
import { getKeyBindingsManager } from "../KeyBindingsManager"; import { getKeyBindingsManager } from "../KeyBindingsManager";
import { KeyBindingAction } from "./KeyboardShortcuts"; import { KeyBindingAction } from "./KeyboardShortcuts";
interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {} interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
ref?: Ref<HTMLDivElement>;
}
// This component implements the Toolbar design pattern from the WAI-ARIA Authoring Practices guidelines. // This component implements the Toolbar design pattern from the WAI-ARIA Authoring Practices guidelines.
// https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar // https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar
// All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref` // All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref`
const Toolbar = forwardRef<HTMLDivElement, IProps>(({ children, ...props }, ref) => { const Toolbar = ({ children, ref, ...props }: IProps): JSX.Element => {
const onKeyDown = (ev: React.KeyboardEvent): void => { const onKeyDown = (ev: React.KeyboardEvent): void => {
const target = ev.target as HTMLElement; const target = ev.target as HTMLElement;
// Don't interfere with input default keydown behaviour // Don't interfere with input default keydown behaviour
@@ -55,6 +57,6 @@ const Toolbar = forwardRef<HTMLDivElement, IProps>(({ children, ...props }, ref)
)} )}
</RovingTabIndexProvider> </RovingTabIndexProvider>
); );
}); };
export default Toolbar; export default Toolbar;

View File

@@ -8,7 +8,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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { forwardRef, type Ref } from "react"; import React, { type Ref, type JSX } from "react";
import AccessibleButton, { type ButtonProps } from "../../components/views/elements/AccessibleButton"; import AccessibleButton, { type ButtonProps } from "../../components/views/elements/AccessibleButton";
@@ -16,13 +16,19 @@ type Props<T extends keyof HTMLElementTagNameMap> = ButtonProps<T> & {
label?: string; label?: string;
// whether the context menu is currently open // whether the context menu is currently open
isExpanded: boolean; isExpanded: boolean;
ref?: Ref<HTMLElementTagNameMap[T]>;
}; };
// Semantic component for representing the AccessibleButton which launches a <ContextMenu /> // Semantic component for representing the AccessibleButton which launches a <ContextMenu />
export const ContextMenuButton = forwardRef(function <T extends keyof HTMLElementTagNameMap>( export const ContextMenuButton = function <T extends keyof HTMLElementTagNameMap>({
{ label, isExpanded, children, onClick, onContextMenu, ...props }: Props<T>, label,
ref: Ref<HTMLElementTagNameMap[T]>, isExpanded,
) { children,
onClick,
onContextMenu,
ref,
...props
}: Props<T>): JSX.Element {
return ( return (
<AccessibleButton <AccessibleButton
{...props} {...props}
@@ -36,4 +42,4 @@ export const ContextMenuButton = forwardRef(function <T extends keyof HTMLElemen
{children} {children}
</AccessibleButton> </AccessibleButton>
); );
}); };

View File

@@ -8,7 +8,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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { forwardRef, type Ref } from "react"; import React, { type JSX } from "react";
import AccessibleButton, { type ButtonProps } from "../../components/views/elements/AccessibleButton"; import AccessibleButton, { type ButtonProps } from "../../components/views/elements/AccessibleButton";
@@ -18,10 +18,14 @@ type Props<T extends keyof HTMLElementTagNameMap> = ButtonProps<T> & {
}; };
// Semantic component for representing the AccessibleButton which launches a <ContextMenu /> // Semantic component for representing the AccessibleButton which launches a <ContextMenu />
export const ContextMenuTooltipButton = forwardRef(function <T extends keyof HTMLElementTagNameMap>( export const ContextMenuTooltipButton = function <T extends keyof HTMLElementTagNameMap>({
{ isExpanded, children, onClick, onContextMenu, ...props }: Props<T>, isExpanded,
ref: Ref<HTMLElementTagNameMap[T]>, children,
) { onClick,
onContextMenu,
ref,
...props
}: Props<T>): JSX.Element {
return ( return (
<AccessibleButton <AccessibleButton
{...props} {...props}
@@ -35,4 +39,4 @@ export const ContextMenuTooltipButton = forwardRef(function <T extends keyof HTM
{children} {children}
</AccessibleButton> </AccessibleButton>
); );
}); };

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { forwardRef } from "react"; import React, { type Ref, type JSX } from "react";
import classNames from "classnames"; import classNames from "classnames";
/* These were earlier stateless functional components but had to be converted /* These were earlier stateless functional components but had to be converted
@@ -16,14 +16,24 @@ presumably wrap them in a <div> before rendering but I think this is the better
*/ */
interface ITextualCompletionProps { interface ITextualCompletionProps {
title?: string; "title"?: string;
subtitle?: string; "subtitle"?: string;
description?: string; "description"?: string;
className?: string; "className"?: string;
"aria-selected"?: boolean;
"ref"?: Ref<HTMLDivElement>;
} }
export const TextualCompletion = forwardRef<ITextualCompletionProps, any>((props, ref) => { export const TextualCompletion = (props: ITextualCompletionProps): JSX.Element => {
const { title, subtitle, description, className, "aria-selected": ariaSelectedAttribute, ...restProps } = props; const {
title,
subtitle,
description,
className,
"aria-selected": ariaSelectedAttribute,
ref,
...restProps
} = props;
return ( return (
<div <div
{...restProps} {...restProps}
@@ -37,13 +47,13 @@ export const TextualCompletion = forwardRef<ITextualCompletionProps, any>((props
<span className="mx_Autocomplete_Completion_description">{description}</span> <span className="mx_Autocomplete_Completion_description">{description}</span>
</div> </div>
); );
}); };
interface IPillCompletionProps extends ITextualCompletionProps { interface IPillCompletionProps extends ITextualCompletionProps {
children?: React.ReactNode; children?: React.ReactNode;
} }
export const PillCompletion = forwardRef<IPillCompletionProps, any>((props, ref) => { export const PillCompletion = (props: IPillCompletionProps): JSX.Element => {
const { const {
title, title,
subtitle, subtitle,
@@ -51,6 +61,7 @@ export const PillCompletion = forwardRef<IPillCompletionProps, any>((props, ref)
className, className,
children, children,
"aria-selected": ariaSelectedAttribute, "aria-selected": ariaSelectedAttribute,
ref,
...restProps ...restProps
} = props; } = props;
return ( return (
@@ -67,4 +78,4 @@ export const PillCompletion = forwardRef<IPillCompletionProps, any>((props, ref)
<span className="mx_Autocomplete_Completion_description">{description}</span> <span className="mx_Autocomplete_Completion_description">{description}</span>
</div> </div>
); );
}); };

View File

@@ -127,7 +127,7 @@ export default class UserProvider extends AutocompleteProvider {
suffix: selection.beginning && range!.start === 0 ? ": " : " ", suffix: selection.beginning && range!.start === 0 ? ": " : " ",
href: makeUserPermalink(user.userId), href: makeUserPermalink(user.userId),
component: ( component: (
<PillCompletion title={displayName} description={description}> <PillCompletion title={displayName} description={description ?? undefined}>
<MemberAvatar member={user} size="24px" /> <MemberAvatar member={user} size="24px" />
</PillCompletion> </PillCompletion>
), ),

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type PropsWithChildren, useEffect, useState } from "react"; import React, { type PropsWithChildren, useEffect, useState, type JSX } from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api"; import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
@@ -85,7 +85,7 @@ interface Props {
* A React component which exposes a {@link MatrixClientContext} and a {@link LocalDeviceVerificationStateContext} * A React component which exposes a {@link MatrixClientContext} and a {@link LocalDeviceVerificationStateContext}
* to its children. * to its children.
*/ */
export function MatrixClientContextProvider(props: PropsWithChildren<Props>): React.JSX.Element { export function MatrixClientContextProvider(props: PropsWithChildren<Props>): JSX.Element {
const verificationState = useLocalVerificationState(props.client); const verificationState = useLocalVerificationState(props.client);
return ( return (
<MatrixClientContext.Provider value={props.client}> <MatrixClientContext.Provider value={props.client}>

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import React, { type JSX, type Ref, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { import {
type ISearchResults, type ISearchResults,
type IThreadBundledRelationship, type IThreadBundledRelationship,
@@ -44,269 +44,275 @@ interface Props {
resizeNotifier: ResizeNotifier; resizeNotifier: ResizeNotifier;
className: string; className: string;
onUpdate(inProgress: boolean, results: ISearchResults | null, error: Error | null): void; onUpdate(inProgress: boolean, results: ISearchResults | null, error: Error | null): void;
ref?: Ref<ScrollPanel>;
} }
// XXX: todo: merge overlapping results somehow? // XXX: todo: merge overlapping results somehow?
// XXX: why doesn't searching on name work? // XXX: why doesn't searching on name work?
export const RoomSearchView = forwardRef<ScrollPanel, Props>( export const RoomSearchView = ({
({ term, scope, promise, abortController, resizeNotifier, className, onUpdate, inProgress }: Props, ref) => { term,
const client = useContext(MatrixClientContext); scope,
const roomContext = useScopedRoomContext("showHiddenEvents"); promise,
const [highlights, setHighlights] = useState<string[] | null>(null); abortController,
const [results, setResults] = useState<ISearchResults | null>(null); resizeNotifier,
const aborted = useRef(false); className,
// A map from room ID to permalink creator onUpdate,
const permalinkCreators = useMemo(() => new Map<string, RoomPermalinkCreator>(), []); inProgress,
const innerRef = useRef<ScrollPanel>(null); ref,
}: Props): JSX.Element => {
const client = useContext(MatrixClientContext);
const roomContext = useScopedRoomContext("showHiddenEvents");
const [highlights, setHighlights] = useState<string[] | null>(null);
const [results, setResults] = useState<ISearchResults | null>(null);
const aborted = useRef(false);
// A map from room ID to permalink creator
const permalinkCreators = useMemo(() => new Map<string, RoomPermalinkCreator>(), []);
const innerRef = useRef<ScrollPanel>(null);
useEffect(() => { useEffect(() => {
return () => { return () => {
permalinkCreators.forEach((pc) => pc.stop()); permalinkCreators.forEach((pc) => pc.stop());
permalinkCreators.clear(); permalinkCreators.clear();
}; };
}, [permalinkCreators]); }, [permalinkCreators]);
const handleSearchResult = useCallback( const handleSearchResult = useCallback(
(searchPromise: Promise<ISearchResults>): Promise<boolean> => { (searchPromise: Promise<ISearchResults>): Promise<boolean> => {
onUpdate(true, null, null); onUpdate(true, null, null);
return searchPromise.then( return searchPromise.then(
async (results): Promise<boolean> => { async (results): Promise<boolean> => {
debuglog("search complete"); debuglog("search complete");
if (aborted.current) { if (aborted.current) {
logger.error("Discarding stale search results"); logger.error("Discarding stale search results");
return false; return false;
} }
// postgres on synapse returns us precise details of the strings // postgres on synapse returns us precise details of the strings
// which actually got matched for highlighting. // which actually got matched for highlighting.
// //
// In either case, we want to highlight the literal search term // In either case, we want to highlight the literal search term
// whether it was used by the search engine or not. // whether it was used by the search engine or not.
let highlights = results.highlights; let highlights = results.highlights;
if (!highlights.includes(term)) { if (!highlights.includes(term)) {
highlights = highlights.concat(term); highlights = highlights.concat(term);
} }
// For overlapping highlights, // For overlapping highlights,
// favour longer (more specific) terms first // favour longer (more specific) terms first
highlights = highlights.sort(function (a, b) { highlights = highlights.sort(function (a, b) {
return b.length - a.length; return b.length - a.length;
}); });
for (const result of results.results) { for (const result of results.results) {
for (const event of result.context.getTimeline()) { for (const event of result.context.getTimeline()) {
const bundledRelationship = const bundledRelationship = event.getServerAggregatedRelation<IThreadBundledRelationship>(
event.getServerAggregatedRelation<IThreadBundledRelationship>( THREAD_RELATION_TYPE.name,
THREAD_RELATION_TYPE.name, );
); if (!bundledRelationship || event.getThread()) continue;
if (!bundledRelationship || event.getThread()) continue; const room = client.getRoom(event.getRoomId());
const room = client.getRoom(event.getRoomId()); const thread = room?.findThreadForEvent(event);
const thread = room?.findThreadForEvent(event); if (thread) {
if (thread) { event.setThread(thread);
event.setThread(thread); } else {
} else { room?.createThread(event.getId()!, event, [], true);
room?.createThread(event.getId()!, event, [], true);
}
} }
} }
}
setHighlights(highlights); setHighlights(highlights);
setResults({ ...results }); // copy to force a refresh setResults({ ...results }); // copy to force a refresh
onUpdate(false, results, null); onUpdate(false, results, null);
return false;
},
(error) => {
if (aborted.current) {
logger.error("Discarding stale search results");
return false; return false;
}, }
(error) => { logger.error("Search failed", error);
if (aborted.current) { onUpdate(false, null, error);
logger.error("Discarding stale search results"); return false;
return false; },
}
logger.error("Search failed", error);
onUpdate(false, null, error);
return false;
},
);
},
[client, term, onUpdate],
);
// Mount & unmount effect
useEffect(() => {
aborted.current = false;
handleSearchResult(promise);
return () => {
aborted.current = true;
abortController?.abort();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// show searching spinner
if (results === null) {
return (
<div
className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner"
data-testid="messagePanelSearchSpinner"
/>
); );
} },
[client, term, onUpdate],
);
const onSearchResultsFillRequest = async (backwards: boolean): Promise<boolean> => { // Mount & unmount effect
if (!backwards) { useEffect(() => {
return false; aborted.current = false;
} handleSearchResult(promise);
return () => {
if (!results.next_batch) { aborted.current = true;
debuglog("no more search results"); abortController?.abort();
return false;
}
debuglog("requesting more search results");
const searchPromise = searchPagination(client, results);
return handleSearchResult(searchPromise);
}; };
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const ret: JSX.Element[] = []; // show searching spinner
if (results === null) {
return (
<div
className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner"
data-testid="messagePanelSearchSpinner"
/>
);
}
if (inProgress) { const onSearchResultsFillRequest = async (backwards: boolean): Promise<boolean> => {
ret.push( if (!backwards) {
<li key="search-spinner"> return false;
<Spinner />
</li>,
);
} }
if (!results.next_batch) { if (!results.next_batch) {
if (!results?.results?.length) { debuglog("no more search results");
return false;
}
debuglog("requesting more search results");
const searchPromise = searchPagination(client, results);
return handleSearchResult(searchPromise);
};
const ret: JSX.Element[] = [];
if (inProgress) {
ret.push(
<li key="search-spinner">
<Spinner />
</li>,
);
}
if (!results.next_batch) {
if (!results?.results?.length) {
ret.push(
<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">{_t("common|no_results")}</h2>
</li>,
);
} else {
ret.push(
<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">{_t("no_more_results")}</h2>
</li>,
);
}
}
const onRef = (e: ScrollPanel | null): void => {
if (typeof ref === "function") {
ref(e);
} else if (!!ref) {
ref.current = e;
}
innerRef.current = e;
};
let lastRoomId: string | undefined;
let mergedTimeline: MatrixEvent[] = [];
let ourEventsIndexes: number[] = [];
for (let i = (results?.results?.length || 0) - 1; i >= 0; i--) {
const result = results.results[i];
const mxEv = result.context.getEvent();
const roomId = mxEv.getRoomId()!;
const room = client.getRoom(roomId);
if (!room) {
// if we do not have the room in js-sdk stores then hide it as we cannot easily show it
// As per the spec, an all rooms search can create this condition,
// it happens with Seshat but not Synapse.
// It will make the result count not match the displayed count.
logger.log("Hiding search result from an unknown room", roomId);
continue;
}
if (!haveRendererForEvent(mxEv, client, roomContext.showHiddenEvents)) {
// XXX: can this ever happen? It will make the result count
// not match the displayed count.
continue;
}
if (scope === SearchScope.All) {
if (roomId !== lastRoomId) {
ret.push( ret.push(
<li key="search-top-marker"> <li key={mxEv.getId() + "-room"}>
<h2 className="mx_RoomView_topMarker">{_t("common|no_results")}</h2> <h2>
</li>, {_t("common|room")}: {room.name}
); </h2>
} else {
ret.push(
<li key="search-top-marker">
<h2 className="mx_RoomView_topMarker">{_t("no_more_results")}</h2>
</li>, </li>,
); );
lastRoomId = roomId;
} }
} }
const onRef = (e: ScrollPanel | null): void => { const resultLink = "#/room/" + roomId + "/" + mxEv.getId();
if (typeof ref === "function") {
ref(e);
} else if (!!ref) {
ref.current = e;
}
innerRef.current = e;
};
let lastRoomId: string | undefined; // merging two successive search result if the query is present in both of them
let mergedTimeline: MatrixEvent[] = []; const currentTimeline = result.context.getTimeline();
let ourEventsIndexes: number[] = []; const nextTimeline = i > 0 ? results.results[i - 1].context.getTimeline() : [];
for (let i = (results?.results?.length || 0) - 1; i >= 0; i--) {
const result = results.results[i];
const mxEv = result.context.getEvent();
const roomId = mxEv.getRoomId()!;
const room = client.getRoom(roomId);
if (!room) {
// if we do not have the room in js-sdk stores then hide it as we cannot easily show it
// As per the spec, an all rooms search can create this condition,
// it happens with Seshat but not Synapse.
// It will make the result count not match the displayed count.
logger.log("Hiding search result from an unknown room", roomId);
continue;
}
if (!haveRendererForEvent(mxEv, client, roomContext.showHiddenEvents)) {
// XXX: can this ever happen? It will make the result count
// not match the displayed count.
continue;
}
if (scope === SearchScope.All) {
if (roomId !== lastRoomId) {
ret.push(
<li key={mxEv.getId() + "-room"}>
<h2>
{_t("common|room")}: {room.name}
</h2>
</li>,
);
lastRoomId = roomId;
}
}
const resultLink = "#/room/" + roomId + "/" + mxEv.getId();
// merging two successive search result if the query is present in both of them
const currentTimeline = result.context.getTimeline();
const nextTimeline = i > 0 ? results.results[i - 1].context.getTimeline() : [];
if (i > 0 && currentTimeline[currentTimeline.length - 1].getId() == nextTimeline[0].getId()) {
// if this is the first searchResult we merge then add all values of the current searchResult
if (mergedTimeline.length == 0) {
for (let j = mergedTimeline.length == 0 ? 0 : 1; j < result.context.getTimeline().length; j++) {
mergedTimeline.push(currentTimeline[j]);
}
ourEventsIndexes.push(result.context.getOurEventIndex());
}
// merge the events of the next searchResult
for (let j = 1; j < nextTimeline.length; j++) {
mergedTimeline.push(nextTimeline[j]);
}
// add the index of the matching event of the next searchResult
ourEventsIndexes.push(
ourEventsIndexes[ourEventsIndexes.length - 1] +
results.results[i - 1].context.getOurEventIndex() +
1,
);
continue;
}
if (i > 0 && currentTimeline[currentTimeline.length - 1].getId() == nextTimeline[0].getId()) {
// if this is the first searchResult we merge then add all values of the current searchResult
if (mergedTimeline.length == 0) { if (mergedTimeline.length == 0) {
mergedTimeline = result.context.getTimeline(); for (let j = mergedTimeline.length == 0 ? 0 : 1; j < result.context.getTimeline().length; j++) {
ourEventsIndexes = []; mergedTimeline.push(currentTimeline[j]);
}
ourEventsIndexes.push(result.context.getOurEventIndex()); ourEventsIndexes.push(result.context.getOurEventIndex());
} }
let permalinkCreator = permalinkCreators.get(roomId); // merge the events of the next searchResult
if (!permalinkCreator) { for (let j = 1; j < nextTimeline.length; j++) {
permalinkCreator = new RoomPermalinkCreator(room); mergedTimeline.push(nextTimeline[j]);
permalinkCreator.start();
permalinkCreators.set(roomId, permalinkCreator);
} }
ret.push( // add the index of the matching event of the next searchResult
<SearchResultTile ourEventsIndexes.push(
key={mxEv.getId()} ourEventsIndexes[ourEventsIndexes.length - 1] + results.results[i - 1].context.getOurEventIndex() + 1,
timeline={mergedTimeline}
ourEventsIndexes={ourEventsIndexes}
searchHighlights={highlights ?? []}
resultLink={resultLink}
permalinkCreator={permalinkCreator}
/>,
); );
ourEventsIndexes = []; continue;
mergedTimeline = [];
} }
return ( if (mergedTimeline.length == 0) {
<ScrollPanel mergedTimeline = result.context.getTimeline();
ref={onRef} ourEventsIndexes = [];
className={"mx_RoomView_searchResultsPanel " + className} ourEventsIndexes.push(result.context.getOurEventIndex());
onFillRequest={onSearchResultsFillRequest} }
resizeNotifier={resizeNotifier}
> let permalinkCreator = permalinkCreators.get(roomId);
<li className="mx_RoomView_scrollheader" /> if (!permalinkCreator) {
{ret} permalinkCreator = new RoomPermalinkCreator(room);
</ScrollPanel> permalinkCreator.start();
permalinkCreators.set(roomId, permalinkCreator);
}
ret.push(
<SearchResultTile
key={mxEv.getId()}
timeline={mergedTimeline}
ourEventsIndexes={ourEventsIndexes}
searchHighlights={highlights ?? []}
resultLink={resultLink}
permalinkCreator={permalinkCreator}
/>,
); );
},
); ourEventsIndexes = [];
mergedTimeline = [];
}
return (
<ScrollPanel
ref={onRef}
className={"mx_RoomView_searchResultsPanel " + className}
onFillRequest={onSearchResultsFillRequest}
resizeNotifier={resizeNotifier}
>
<li className="mx_RoomView_scrollheader" />
{ret}
</ScrollPanel>
);
};

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; import React, { type JSX } from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api"; import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
@@ -43,13 +43,13 @@ type MigrationState = {
/** /**
* The view that is displayed after we have logged in, before the first /sync is completed. * 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( const migrationState = useTypedEventEmitterState(
props.matrixClient, props.matrixClient,
CryptoEvent.LegacyCryptoStoreMigrationProgress, CryptoEvent.LegacyCryptoStoreMigrationProgress,
(progress?: number, total?: number): MigrationState => ({ progress: progress ?? -1, totalSteps: total ?? -1 }), (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) { if (props.syncError) {
errorBox = <div className="mx_LoginSplashView_syncError">{messageForSyncError(props.syncError)}</div>; errorBox = <div className="mx_LoginSplashView_syncError">{messageForSyncError(props.syncError)}</div>;
} }

View File

@@ -9,7 +9,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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type 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 classNames from "classnames";
import { ClientEvent, type 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 { Avatar } from "@vector-im/compound-web";
@@ -34,6 +34,7 @@ interface IProps {
tabIndex?: number; tabIndex?: number;
altText?: string; altText?: string;
role?: AriaRole; role?: AriaRole;
ref?: Ref<HTMLElement>;
} }
const calculateUrls = (url?: string | null, urls?: string[], lowBandwidth = false): string[] => { 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]; return [imageUrl, onError];
}; };
const BaseAvatar = forwardRef<HTMLElement, IProps>((props, ref) => { const BaseAvatar = (props: IProps): JSX.Element => {
const { const {
name, name,
idName, idName,
@@ -99,6 +100,7 @@ const BaseAvatar = forwardRef<HTMLElement, IProps>((props, ref) => {
className, className,
type = "round", type = "round",
altText = _t("common|avatar"), altText = _t("common|avatar"),
ref,
...otherProps ...otherProps
} = props; } = props;
@@ -134,7 +136,7 @@ const BaseAvatar = forwardRef<HTMLElement, IProps>((props, ref) => {
data-testid="avatar-img" data-testid="avatar-img"
/> />
); );
}); };
export default BaseAvatar; export default BaseAvatar;
export type BaseAvatarType = React.FC<IProps>; export type BaseAvatarType = React.FC<IProps>;

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, forwardRef, type ReactNode, type Ref, useContext } from "react"; import React, { type JSX, type ReactNode, type Ref, useContext } from "react";
import { type RoomMember, type ResizeMethod } from "matrix-js-sdk/src/matrix"; import { type RoomMember, type ResizeMethod } from "matrix-js-sdk/src/matrix";
import dis from "../../../dispatcher/dispatcher"; import dis from "../../../dispatcher/dispatcher";
@@ -33,21 +33,20 @@ interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" |
forceHistorical?: boolean; // true to deny `useOnlyCurrentProfiles` usage. Default false. forceHistorical?: boolean; // true to deny `useOnlyCurrentProfiles` usage. Default false.
hideTitle?: boolean; hideTitle?: boolean;
children?: ReactNode; children?: ReactNode;
ref?: Ref<HTMLElement>;
} }
function MemberAvatar( export default function MemberAvatar({
{ size,
size, resizeMethod = "crop",
resizeMethod = "crop", viewUserOnClick,
viewUserOnClick, forceHistorical,
forceHistorical, fallbackUserId,
fallbackUserId, hideTitle,
hideTitle, member: propsMember,
member: propsMember, ref,
...props ...props
}: IProps, }: IProps): JSX.Element {
ref: Ref<HTMLElement>,
): JSX.Element {
const cli = useContext(MatrixClientContext); const cli = useContext(MatrixClientContext);
const card = useContext(CardContext); const card = useContext(CardContext);
@@ -101,5 +100,3 @@ function MemberAvatar(
/> />
); );
} }
export default forwardRef(MemberAvatar);

View File

@@ -10,8 +10,6 @@ import React, {
type JSX, type JSX,
type ComponentProps, type ComponentProps,
type ComponentPropsWithoutRef, type ComponentPropsWithoutRef,
forwardRef,
type FunctionComponent,
type ReactElement, type ReactElement,
type KeyboardEvent, type KeyboardEvent,
type Ref, type Ref,
@@ -100,6 +98,8 @@ type Props<T extends ElementType = "div"> = {
* Whether the tooltip should be disabled. * Whether the tooltip should be disabled.
*/ */
disableTooltip?: TooltipProps["disabled"]; disableTooltip?: TooltipProps["disabled"];
ref?: Ref<HTMLElementTagNameMap[T]>;
}; };
export type ButtonProps<T extends ElementType> = Props<T> & Omit<ComponentPropsWithoutRef<T>, keyof Props<T>>; export type ButtonProps<T extends ElementType> = Props<T> & Omit<ComponentPropsWithoutRef<T>, keyof Props<T>>;
@@ -119,28 +119,26 @@ type RenderedElementProps<T extends ElementType> = React.InputHTMLAttributes<Ele
* @param {Object} props react element properties * @param {Object} props react element properties
* @returns {Object} rendered react * @returns {Object} rendered react
*/ */
const AccessibleButton = forwardRef(function <T extends ElementType = typeof defaultElement>( const AccessibleButton = function AccessibleButton<T extends ElementType = typeof defaultElement>({
{ element,
element, onClick,
onClick, children,
children, kind,
kind, disabled,
disabled, className,
className, onKeyDown,
onKeyDown, onKeyUp,
onKeyUp, triggerOnMouseDown,
triggerOnMouseDown, title,
title, caption,
caption, placement = "right",
placement = "right", onTooltipOpenChange,
onTooltipOpenChange, disableTooltip,
disableTooltip, role = "button",
role = "button", tabIndex = 0,
tabIndex = 0, ref,
...restProps ...restProps
}: ButtonProps<T>, }: ButtonProps<T>): JSX.Element {
ref: Ref<HTMLElementTagNameMap[T]>,
): JSX.Element {
const newProps = { const newProps = {
...restProps, ...restProps,
tabIndex, tabIndex,
@@ -226,10 +224,7 @@ const AccessibleButton = forwardRef(function <T extends ElementType = typeof def
); );
} }
return button; return button;
}); };
// Type assertion required due to forwardRef type workaround in react.d.ts
(AccessibleButton as FunctionComponent).displayName = "AccessibleButton";
interface RefProp<T extends ElementType> { interface RefProp<T extends ElementType> {
ref?: Ref<HTMLElementTagNameMap[T]>; ref?: Ref<HTMLElementTagNameMap[T]>;

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type ReactNode, useState } from "react"; import React, { type JSX, type ReactNode, type Ref, useState } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { type RoomMember } from "matrix-js-sdk/src/matrix"; import { type RoomMember } from "matrix-js-sdk/src/matrix";
import LocationIcon from "@vector-im/compound-design-tokens/assets/web/icons/location-pin-solid"; import LocationIcon from "@vector-im/compound-design-tokens/assets/web/icons/location-pin-solid";
@@ -21,6 +21,7 @@ interface Props {
// use member text color as background // use member text color as background
useMemberColor?: boolean; useMemberColor?: boolean;
tooltip?: ReactNode; tooltip?: ReactNode;
ref?: Ref<HTMLDivElement>;
} }
/** /**
@@ -55,7 +56,7 @@ const OptionalTooltip: React.FC<{
/** /**
* Generic location marker * Generic location marker
*/ */
const Marker = React.forwardRef<HTMLDivElement, Props>(({ id, roomMember, useMemberColor, tooltip }, ref) => { const Marker = ({ id, roomMember, useMemberColor, tooltip, ref }: Props): JSX.Element => {
const memberColorClass = useMemberColor && roomMember ? getUserNameColorClass(roomMember.userId) : ""; const memberColorClass = useMemberColor && roomMember ? getUserNameColorClass(roomMember.userId) : "";
return ( return (
<div <div
@@ -82,6 +83,6 @@ const Marker = React.forwardRef<HTMLDivElement, Props>(({ id, roomMember, useMem
</OptionalTooltip> </OptionalTooltip>
</div> </div>
); );
}); };
export default Marker; export default Marker;

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { forwardRef, useCallback, useContext, useMemo } from "react"; import React, { type Ref, useCallback, useContext, useMemo, type JSX } from "react";
import type { MatrixEvent, RoomMember } from "matrix-js-sdk/src/matrix"; import type { MatrixEvent, RoomMember } from "matrix-js-sdk/src/matrix";
import { ConnectionState, type ElementCall } from "../../../models/Call"; import { ConnectionState, type ElementCall } from "../../../models/Call";
@@ -37,60 +37,69 @@ interface ActiveCallEventProps {
buttonKind: AccessibleButtonKind; buttonKind: AccessibleButtonKind;
buttonDisabledTooltip?: string; buttonDisabledTooltip?: string;
onButtonClick: ((ev: ButtonEvent) => void) | null; onButtonClick: ((ev: ButtonEvent) => void) | null;
ref?: Ref<HTMLDivElement>;
} }
const ActiveCallEvent = forwardRef<any, ActiveCallEventProps>( const ActiveCallEvent = ({
({ mxEvent, call, participatingMembers, buttonText, buttonKind, buttonDisabledTooltip, onButtonClick }, ref) => { mxEvent,
const senderName = useMemo(() => mxEvent.sender?.name ?? mxEvent.getSender(), [mxEvent]); call,
participatingMembers,
buttonText,
buttonKind,
buttonDisabledTooltip,
onButtonClick,
ref,
}: ActiveCallEventProps): JSX.Element => {
const senderName = useMemo(() => mxEvent.sender?.name ?? mxEvent.getSender(), [mxEvent]);
const facePileMembers = useMemo(() => participatingMembers.slice(0, MAX_FACES), [participatingMembers]); const facePileMembers = useMemo(() => participatingMembers.slice(0, MAX_FACES), [participatingMembers]);
const facePileOverflow = participatingMembers.length > facePileMembers.length; const facePileOverflow = participatingMembers.length > facePileMembers.length;
return ( return (
<div className="mx_CallEvent_wrapper" ref={ref}> <div className="mx_CallEvent_wrapper" ref={ref}>
<div className="mx_CallEvent mx_CallEvent_active"> <div className="mx_CallEvent mx_CallEvent_active">
<MemberAvatar <MemberAvatar
member={mxEvent.sender} member={mxEvent.sender}
fallbackUserId={mxEvent.getSender()} fallbackUserId={mxEvent.getSender()}
viewUserOnClick viewUserOnClick
size="24px" size="24px"
/> />
<div className="mx_CallEvent_columns"> <div className="mx_CallEvent_columns">
<div className="mx_CallEvent_details"> <div className="mx_CallEvent_details">
<span className="mx_CallEvent_title"> <span className="mx_CallEvent_title">
{_t("timeline|m.call|video_call_started_text", { name: senderName })} {_t("timeline|m.call|video_call_started_text", { name: senderName })}
</span> </span>
<LiveContentSummary <LiveContentSummary
type={LiveContentType.Video} type={LiveContentType.Video}
text={_t("voip|video_call")} text={_t("voip|video_call")}
active={false} active={false}
participantCount={participatingMembers.length} participantCount={participatingMembers.length}
/> />
<FacePile members={facePileMembers} size="24px" overflow={facePileOverflow} /> <FacePile members={facePileMembers} size="24px" overflow={facePileOverflow} />
</div>
{call && <SessionDuration session={call.session} />}
<AccessibleButton
className="mx_CallEvent_button"
kind={buttonKind}
disabled={onButtonClick === null || buttonDisabledTooltip !== undefined}
onClick={onButtonClick}
title={buttonDisabledTooltip}
>
{buttonText}
</AccessibleButton>
</div> </div>
{call && <SessionDuration session={call.session} />}
<AccessibleButton
className="mx_CallEvent_button"
kind={buttonKind}
disabled={onButtonClick === null || buttonDisabledTooltip !== undefined}
onClick={onButtonClick}
title={buttonDisabledTooltip}
>
{buttonText}
</AccessibleButton>
</div> </div>
</div> </div>
); </div>
}, );
); };
interface ActiveLoadedCallEventProps { interface ActiveLoadedCallEventProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
call: ElementCall; call: ElementCall;
ref?: Ref<HTMLDivElement>;
} }
const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxEvent, call }, ref) => { const ActiveLoadedCallEvent = ({ mxEvent, call, ref }: ActiveLoadedCallEventProps): JSX.Element => {
const connectionState = useConnectionState(call); const connectionState = useConnectionState(call);
const participatingMembers = useParticipatingMembers(call); const participatingMembers = useParticipatingMembers(call);
const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call); const joinCallButtonDisabledTooltip = useJoinCallButtonDisabledTooltip(call);
@@ -141,16 +150,17 @@ const ActiveLoadedCallEvent = forwardRef<any, ActiveLoadedCallEventProps>(({ mxE
onButtonClick={onButtonClick} onButtonClick={onButtonClick}
/> />
); );
}); };
interface CallEventProps { interface CallEventProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
ref?: Ref<HTMLDivElement>;
} }
/** /**
* An event tile representing an active or historical Element call. * An event tile representing an active or historical Element call.
*/ */
export const CallEvent = forwardRef<any, CallEventProps>(({ mxEvent }, ref) => { export const CallEvent = ({ mxEvent, ref }: CallEventProps): JSX.Element => {
const client = useContext(MatrixClientContext); const client = useContext(MatrixClientContext);
const call = useCall(mxEvent.getRoomId()!); const call = useCall(mxEvent.getRoomId()!);
const latestEvent = client const latestEvent = client
@@ -187,4 +197,4 @@ export const CallEvent = forwardRef<any, CallEventProps>(({ mxEvent }, ref) => {
} }
return <ActiveLoadedCallEvent mxEvent={mxEvent} call={call as ElementCall} ref={ref} />; return <ActiveLoadedCallEvent mxEvent={mxEvent} call={call as ElementCall} ref={ref} />;
}); };

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/ */
import classNames from "classnames"; import classNames from "classnames";
import React, { forwardRef, type ForwardRefExoticComponent, useContext } from "react"; import React, { type JSX, useContext } from "react";
import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api"; import { DecryptionFailureCode } from "matrix-js-sdk/src/crypto-api";
import { BlockIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { BlockIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
@@ -16,7 +16,7 @@ import { _t } from "../../../languageHandler";
import { type IBodyProps } from "./IBodyProps"; import { type IBodyProps } from "./IBodyProps";
import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext"; import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDeviceVerificationStateContext";
function getErrorMessage(mxEvent: MatrixEvent, isVerified: boolean | undefined): string | React.JSX.Element { function getErrorMessage(mxEvent: MatrixEvent, isVerified: boolean | undefined): string | JSX.Element {
switch (mxEvent.decryptionFailureReason) { switch (mxEvent.decryptionFailureReason) {
case DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE: case DecryptionFailureCode.MEGOLM_KEY_WITHHELD_FOR_UNVERIFIED_DEVICE:
return _t("timeline|decryption_failure|blocked"); return _t("timeline|decryption_failure|blocked");
@@ -72,7 +72,7 @@ function errorClassName(mxEvent: MatrixEvent): string | null {
} }
// A placeholder element for messages that could not be decrypted // A placeholder element for messages that could not be decrypted
export const DecryptionFailureBody = forwardRef<HTMLDivElement, IBodyProps>(({ mxEvent }, ref): React.JSX.Element => { export const DecryptionFailureBody = ({ mxEvent, ref }: IBodyProps): JSX.Element => {
const verificationState = useContext(LocalDeviceVerificationStateContext); const verificationState = useContext(LocalDeviceVerificationStateContext);
const classes = classNames("mx_DecryptionFailureBody", "mx_EventTile_content", errorClassName(mxEvent)); const classes = classNames("mx_DecryptionFailureBody", "mx_EventTile_content", errorClassName(mxEvent));
@@ -81,4 +81,4 @@ export const DecryptionFailureBody = forwardRef<HTMLDivElement, IBodyProps>(({ m
{getErrorMessage(mxEvent, verificationState)} {getErrorMessage(mxEvent, verificationState)}
</div> </div>
); );
}) as ForwardRefExoticComponent<IBodyProps>; };

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, forwardRef } from "react"; import React, { type JSX, type Ref, type ReactNode } from "react";
import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import type { RoomEncryptionEventContent } from "matrix-js-sdk/src/types"; import type { RoomEncryptionEventContent } from "matrix-js-sdk/src/types";
@@ -22,9 +22,10 @@ import { useIsEncrypted } from "../../../hooks/useIsEncrypted.ts";
interface IProps { interface IProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
timestamp?: JSX.Element; timestamp?: JSX.Element;
ref?: Ref<HTMLDivElement>;
} }
const EncryptionEvent = forwardRef<HTMLDivElement, IProps>(({ mxEvent, timestamp }, ref) => { const EncryptionEvent = ({ mxEvent, timestamp, ref }: IProps): ReactNode => {
const cli = useMatrixClientContext(); const cli = useMatrixClientContext();
const roomId = mxEvent.getRoomId()!; const roomId = mxEvent.getRoomId()!;
const isRoomEncrypted = useIsEncrypted(cli, cli.getRoom(roomId) || undefined); const isRoomEncrypted = useIsEncrypted(cli, cli.getRoom(roomId) || undefined);
@@ -80,6 +81,6 @@ const EncryptionEvent = forwardRef<HTMLDivElement, IProps>(({ mxEvent, timestamp
timestamp={timestamp} timestamp={timestamp}
/> />
); );
}); };
export default EncryptionEvent; export default EncryptionEvent;

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { memo, forwardRef, useContext, useMemo } from "react"; import React, { memo, useContext, useMemo, type Ref } from "react";
import { type IContent, type MatrixEvent, MsgType, PushRuleKind } from "matrix-js-sdk/src/matrix"; import { type IContent, type MatrixEvent, MsgType, PushRuleKind } from "matrix-js-sdk/src/matrix";
import parse from "html-react-parser"; import parse from "html-react-parser";
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor"; import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
@@ -139,6 +139,7 @@ interface Props extends ReplacerOptions {
* Whether to include the `dir="auto"` attribute on the rendered element * Whether to include the `dir="auto"` attribute on the rendered element
*/ */
includeDir?: boolean; includeDir?: boolean;
ref?: Ref<HTMLElement>;
} }
/** /**
@@ -148,52 +149,50 @@ interface Props extends ReplacerOptions {
* Returns a div or span depending on `as`, the `dir` on a `div` is always set to `"auto"` but set by `includeDir` otherwise. * Returns a div or span depending on `as`, the `dir` on a `div` is always set to `"auto"` but set by `includeDir` otherwise.
*/ */
const EventContentBody = memo( const EventContentBody = memo(
forwardRef<HTMLElement, Props>( ({ as, mxEvent, stripReply, content, linkify, highlights, includeDir = true, ref, ...options }: Props) => {
({ as, mxEvent, stripReply, content, linkify, highlights, includeDir = true, ...options }, ref) => { const enableBigEmoji = useSettingValue("TextualBody.enableBigEmoji");
const enableBigEmoji = useSettingValue("TextualBody.enableBigEmoji"); const [mediaIsVisible] = useMediaVisible(mxEvent?.getId(), mxEvent?.getRoomId());
const [mediaIsVisible] = useMediaVisible(mxEvent?.getId(), mxEvent?.getRoomId());
const replacer = useReplacer(content, mxEvent, options); const replacer = useReplacer(content, mxEvent, options);
const linkifyOptions = useMemo( const linkifyOptions = useMemo(
() => ({ () => ({
render: replacerToRenderFunction(replacer), render: replacerToRenderFunction(replacer),
}),
[replacer],
);
const isEmote = content.msgtype === MsgType.Emote;
const { strippedBody, formattedBody, emojiBodyElements, className } = useMemo(
() =>
bodyToNode(content, highlights, {
disableBigEmoji: isEmote || !enableBigEmoji,
// Part of Replies fallback support
stripReplyFallback: stripReply,
mediaIsVisible,
}), }),
[replacer], [content, mediaIsVisible, enableBigEmoji, highlights, isEmote, stripReply],
); );
const isEmote = content.msgtype === MsgType.Emote; if (as === "div") includeDir = true; // force dir="auto" on divs
const { strippedBody, formattedBody, emojiBodyElements, className } = useMemo( const As = as;
() => const body = formattedBody ? (
bodyToNode(content, highlights, { <As ref={ref as any} className={className} dir={includeDir ? "auto" : undefined}>
disableBigEmoji: isEmote || !enableBigEmoji, {parse(formattedBody, {
// Part of Replies fallback support replace: replacer,
stripReplyFallback: stripReply, })}
mediaIsVisible, </As>
}), ) : (
[content, mediaIsVisible, enableBigEmoji, highlights, isEmote, stripReply], <As ref={ref as any} className={className} dir={includeDir ? "auto" : undefined}>
); {applyReplacerOnString(emojiBodyElements || strippedBody, replacer)}
</As>
);
if (as === "div") includeDir = true; // force dir="auto" on divs if (!linkify) return body;
const As = as; return <Linkify options={linkifyOptions}>{body}</Linkify>;
const body = formattedBody ? ( },
<As ref={ref as any} className={className} dir={includeDir ? "auto" : undefined}>
{parse(formattedBody, {
replace: replacer,
})}
</As>
) : (
<As ref={ref as any} className={className} dir={includeDir ? "auto" : undefined}>
{applyReplacerOnString(emojiBodyElements || strippedBody, replacer)}
</As>
);
if (!linkify) return body;
return <Linkify options={linkifyOptions}>{body}</Linkify>;
},
),
); );
export default EventContentBody; export default EventContentBody;

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, forwardRef, type ReactNode } from "react"; import React, { type JSX, type ReactNode, type Ref } from "react";
import classNames from "classnames"; import classNames from "classnames";
interface IProps { interface IProps {
@@ -15,19 +15,18 @@ interface IProps {
timestamp?: JSX.Element; timestamp?: JSX.Element;
subtitle?: ReactNode; subtitle?: ReactNode;
children?: JSX.Element; children?: JSX.Element;
ref?: Ref<HTMLDivElement>;
} }
const EventTileBubble = forwardRef<HTMLDivElement, IProps>( const EventTileBubble = ({ className, title, timestamp, subtitle, children, ref }: IProps): JSX.Element => {
({ className, title, timestamp, subtitle, children }, ref) => { return (
return ( <div className={classNames("mx_EventTileBubble", className)} ref={ref}>
<div className={classNames("mx_EventTileBubble", className)} ref={ref}> <div className="mx_EventTileBubble_title">{title}</div>
<div className="mx_EventTileBubble_title">{title}</div> {subtitle && <div className="mx_EventTileBubble_subtitle">{subtitle}</div>}
{subtitle && <div className="mx_EventTileBubble_subtitle">{subtitle}</div>} {children}
{children} {timestamp}
{timestamp} </div>
</div> );
); };
},
);
export default EventTileBubble; export default EventTileBubble;

View File

@@ -6,23 +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. Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; import React, { type JSX } from "react";
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { type IBodyProps } from "./IBodyProps"; import { type IBodyProps } from "./IBodyProps";
interface IProps {
mxEvent: MatrixEvent;
}
/** /**
* A message hidden from the user pending moderation. * A message hidden from the user pending moderation.
* *
* Note: This component must not be used when the user is the author of the message * Note: This component must not be used when the user is the author of the message
* or has a sufficient powerlevel to see the message. * or has a sufficient powerlevel to see the message.
*/ */
const HiddenBody = React.forwardRef<any, IProps | IBodyProps>(({ mxEvent }, ref) => { const HiddenBody = ({ mxEvent, ref }: IBodyProps): JSX.Element => {
let text; let text;
const visibility = mxEvent.messageVisibility(); const visibility = mxEvent.messageVisibility();
switch (visibility.visible) { switch (visibility.visible) {
@@ -42,6 +37,6 @@ const HiddenBody = React.forwardRef<any, IProps | IBodyProps>(({ mxEvent }, ref)
{text} {text}
</span> </span>
); );
}); };
export default HiddenBody; export default HiddenBody;

View File

@@ -6,7 +6,6 @@ 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. Please see LICENSE files in the repository root for full details.
*/ */
import { type LegacyRef } from "react";
import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import type React from "react"; import type React from "react";
@@ -44,7 +43,7 @@ export interface IBodyProps {
// helper function to access relations for this event // helper function to access relations for this event
getRelationsForEvent?: GetRelationsForEvent; getRelationsForEvent?: GetRelationsForEvent;
ref?: React.RefObject<any> | LegacyRef<any>; ref?: React.RefObject<any>;
// Set to `true` to disable interactions (e.g. video controls) and to remove controls from the tab order. // Set to `true` to disable interactions (e.g. video controls) and to remove controls from the tab order.
// This may be useful when displaying a preview of the event. // This may be useful when displaying a preview of the event.

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, type ForwardRefExoticComponent, useCallback, useContext, useEffect, useState } from "react"; import React, { type JSX, useCallback, useContext, useEffect, useState } from "react";
import { import {
type Beacon, type Beacon,
BeaconEvent, BeaconEvent,
@@ -122,7 +122,7 @@ const useHandleBeaconRedaction = (
}, [event, onBeforeBeaconInfoRedaction]); }, [event, onBeforeBeaconInfoRedaction]);
}; };
const MBeaconBody = React.forwardRef<HTMLDivElement, IBodyProps>(({ mxEvent, getRelationsForEvent }, ref) => { const MBeaconBody = ({ mxEvent, getRelationsForEvent, ref }: IBodyProps): JSX.Element => {
const { beacon, isLive, latestLocationState, waitingToStart } = useBeaconState(mxEvent); const { beacon, isLive, latestLocationState, waitingToStart } = useBeaconState(mxEvent);
const mapId = useUniqueId(mxEvent.getId()!); const mapId = useUniqueId(mxEvent.getId()!);
@@ -225,6 +225,6 @@ const MBeaconBody = React.forwardRef<HTMLDivElement, IBodyProps>(({ mxEvent, get
)} )}
</div> </div>
); );
}) as ForwardRefExoticComponent<IBodyProps>; };
export default MBeaconBody; export default MBeaconBody;

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { useEffect, useState, useContext, type ForwardRefExoticComponent } from "react"; import React, { useEffect, useState, useContext, type JSX } from "react";
import { MatrixEvent, M_TEXT } from "matrix-js-sdk/src/matrix"; import { MatrixEvent, M_TEXT } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
@@ -85,7 +85,7 @@ const usePollStartEvent = (event: MatrixEvent): { pollStartEvent?: MatrixEvent;
return { pollStartEvent, isLoadingPollStartEvent }; return { pollStartEvent, isLoadingPollStartEvent };
}; };
export const MPollEndBody = React.forwardRef<any, IBodyProps>(({ mxEvent, ...props }, ref) => { export const MPollEndBody = ({ mxEvent, ref, ...props }: IBodyProps): JSX.Element => {
const cli = useMatrixClientContext(); const cli = useMatrixClientContext();
const { pollStartEvent, isLoadingPollStartEvent } = usePollStartEvent(mxEvent); const { pollStartEvent, isLoadingPollStartEvent } = usePollStartEvent(mxEvent);
@@ -105,4 +105,4 @@ export const MPollEndBody = React.forwardRef<any, IBodyProps>(({ mxEvent, ...pro
<MPollBody mxEvent={pollStartEvent} {...props} /> <MPollBody mxEvent={pollStartEvent} {...props} />
</div> </div>
); );
}) as ForwardRefExoticComponent<IBodyProps>; };

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type ForwardRefExoticComponent, useContext } from "react"; import React, { useContext, type JSX } from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { type MatrixClient } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
@@ -15,7 +15,7 @@ import { formatFullDate } from "../../../DateUtils";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { type IBodyProps } from "./IBodyProps"; import { type IBodyProps } from "./IBodyProps";
const RedactedBody = React.forwardRef<any, IBodyProps>(({ mxEvent }, ref) => { const RedactedBody = ({ mxEvent, ref }: IBodyProps): JSX.Element => {
const cli: MatrixClient = useContext(MatrixClientContext); const cli: MatrixClient = useContext(MatrixClientContext);
let text = _t("timeline|self_redaction"); let text = _t("timeline|self_redaction");
const unsigned = mxEvent.getUnsigned(); const unsigned = mxEvent.getUnsigned();
@@ -37,6 +37,6 @@ const RedactedBody = React.forwardRef<any, IBodyProps>(({ mxEvent }, ref) => {
{text} {text}
</span> </span>
); );
}) as ForwardRefExoticComponent<IBodyProps>; };
export default RedactedBody; export default RedactedBody;

View File

@@ -7,16 +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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { forwardRef, type ForwardRefExoticComponent } from "react"; import React, { type JSX } from "react";
import { type IBodyProps } from "./IBodyProps"; import { type IBodyProps } from "./IBodyProps";
export default forwardRef<HTMLDivElement, IBodyProps>(({ mxEvent, children }, ref) => { export default ({ mxEvent, ref }: IBodyProps): JSX.Element => {
const text = mxEvent.getContent().body; const text = mxEvent.getContent().body;
return ( return (
<div className="mx_UnknownBody" ref={ref}> <div className="mx_UnknownBody" ref={ref}>
{text} {text}
{children}
</div> </div>
); );
}) as ForwardRefExoticComponent<IBodyProps>; };

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { forwardRef, type ReactNode, type KeyboardEvent, type Ref, type MouseEvent } from "react"; import React, { type ReactNode, type KeyboardEvent, type Ref, type MouseEvent } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { IconButton, Text } from "@vector-im/compound-web"; import { IconButton, Text } from "@vector-im/compound-web";
import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close"; import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
@@ -44,106 +44,102 @@ function closeRightPanel(ev: MouseEvent<HTMLButtonElement>): void {
RightPanelStore.instance.popCard(); RightPanelStore.instance.popCard();
} }
const BaseCard: React.FC<IProps> = forwardRef<HTMLDivElement, IProps>( const BaseCard: React.FC<IProps> = ({
( closeLabel,
{ onClose,
closeLabel, onBack,
onClose, className,
onBack, id,
className, ariaLabelledBy,
id, role,
ariaLabelledBy, hideHeaderButtons,
role, header,
hideHeaderButtons, footer,
header, withoutScrollContainer,
footer, children,
withoutScrollContainer, onKeyDown,
children, closeButtonRef,
onKeyDown, ref,
closeButtonRef, }: IProps) => {
}, let backButton;
ref, const cardHistory = RightPanelStore.instance.roomPhaseHistory;
) => { if (cardHistory.length > 1 && !hideHeaderButtons) {
let backButton; const prevCard = cardHistory[cardHistory.length - 2];
const cardHistory = RightPanelStore.instance.roomPhaseHistory; const onBackClick = (ev: MouseEvent<HTMLButtonElement>): void => {
if (cardHistory.length > 1 && !hideHeaderButtons) { onBack?.(ev);
const prevCard = cardHistory[cardHistory.length - 2]; RightPanelStore.instance.popCard();
const onBackClick = (ev: MouseEvent<HTMLButtonElement>): void => { };
onBack?.(ev); const label = backLabelForPhase(prevCard.phase) ?? _t("action|back");
RightPanelStore.instance.popCard(); backButton = (
}; <IconButton
const label = backLabelForPhase(prevCard.phase) ?? _t("action|back"); size="28px"
backButton = ( data-testid="base-card-back-button"
<IconButton onClick={onBackClick}
size="28px" tooltip={label}
data-testid="base-card-back-button" subtleBackground
onClick={onBackClick} >
tooltip={label} <ChevronLeftIcon />
subtleBackground </IconButton>
>
<ChevronLeftIcon />
</IconButton>
);
}
let closeButton;
if (!hideHeaderButtons) {
closeButton = (
<IconButton
size="28px"
data-testid="base-card-close-button"
onClick={onClose ?? closeRightPanel}
ref={closeButtonRef}
tooltip={closeLabel ?? _t("action|close")}
subtleBackground
>
<CloseIcon />
</IconButton>
);
}
if (!withoutScrollContainer) {
children = <AutoHideScrollbar>{children}</AutoHideScrollbar>;
}
const shouldRenderHeader = header || !hideHeaderButtons;
return (
<CardContext.Provider value={{ isCard: true }}>
<div
id={id}
aria-labelledby={ariaLabelledBy}
role={role}
className={classNames("mx_BaseCard", className)}
ref={ref}
onKeyDown={onKeyDown}
>
{shouldRenderHeader && (
<div className="mx_BaseCard_header">
{backButton}
{typeof header === "string" ? (
<div className="mx_BaseCard_header_title">
<Text
size="md"
weight="medium"
className="mx_BaseCard_header_title_heading"
role="heading"
>
{header}
</Text>
</div>
) : (
(header ?? <div className="mx_BaseCard_header_spacer" />)
)}
{closeButton}
</div>
)}
{children}
{footer && <div className="mx_BaseCard_footer">{footer}</div>}
</div>
</CardContext.Provider>
); );
}, }
);
let closeButton;
if (!hideHeaderButtons) {
closeButton = (
<IconButton
size="28px"
data-testid="base-card-close-button"
onClick={onClose ?? closeRightPanel}
ref={closeButtonRef}
tooltip={closeLabel ?? _t("action|close")}
subtleBackground
>
<CloseIcon />
</IconButton>
);
}
if (!withoutScrollContainer) {
children = <AutoHideScrollbar>{children}</AutoHideScrollbar>;
}
const shouldRenderHeader = header || !hideHeaderButtons;
return (
<CardContext.Provider value={{ isCard: true }}>
<div
id={id}
aria-labelledby={ariaLabelledBy}
role={role}
className={classNames("mx_BaseCard", className)}
ref={ref}
onKeyDown={onKeyDown}
>
{shouldRenderHeader && (
<div className="mx_BaseCard_header">
{backButton}
{typeof header === "string" ? (
<div className="mx_BaseCard_header_title">
<Text
size="md"
weight="medium"
className="mx_BaseCard_header_title_heading"
role="heading"
>
{header}
</Text>
</div>
) : (
(header ?? <div className="mx_BaseCard_header_spacer" />)
)}
{closeButton}
</div>
)}
{children}
{footer && <div className="mx_BaseCard_footer">{footer}</div>}
</div>
</CardContext.Provider>
);
};
export default BaseCard; export default BaseCard;

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { createRef, forwardRef, type JSX, type MouseEvent, type ReactNode } from "react"; import React, { createRef, type JSX, type Ref, type MouseEvent, type ReactNode } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { import {
EventStatus, EventStatus,
@@ -228,6 +228,8 @@ export interface EventTileProps {
// The following properties are used by EventTilePreview to disable tab indexes within the event tile // The following properties are used by EventTilePreview to disable tab indexes within the event tile
hideTimestamp?: boolean; hideTimestamp?: boolean;
inhibitInteraction?: boolean; inhibitInteraction?: boolean;
ref?: Ref<UnwrappedEventTile>;
} }
interface IState { interface IState {
@@ -1482,15 +1484,13 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
} }
// Wrap all event tiles with the tile error boundary so that any throws even during construction are captured // Wrap all event tiles with the tile error boundary so that any throws even during construction are captured
const SafeEventTile = forwardRef<UnwrappedEventTile, EventTileProps>((props, ref) => { const SafeEventTile = (props: EventTileProps): JSX.Element => {
return ( return (
<> <TileErrorBoundary mxEvent={props.mxEvent} layout={props.layout ?? Layout.Group}>
<TileErrorBoundary mxEvent={props.mxEvent} layout={props.layout ?? Layout.Group}> <UnwrappedEventTile {...props} />
<UnwrappedEventTile ref={ref} {...props} /> </TileErrorBoundary>
</TileErrorBoundary>
</>
); );
}); };
export default SafeEventTile; export default SafeEventTile;
function E2ePadlockUnencrypted(props: Omit<IE2ePadlockProps, "title" | "icon">): JSX.Element { function E2ePadlockUnencrypted(props: Omit<IE2ePadlockProps, "title" | "icon">): JSX.Element {

View File

@@ -6,7 +6,7 @@ Please see LICENSE files in the repository root for full details.
*/ */
import { Form } from "@vector-im/compound-web"; import { Form } from "@vector-im/compound-web";
import React from "react"; import React, { type JSX } from "react";
import { List, type ListRowProps } from "react-virtualized/dist/commonjs/List"; import { List, type ListRowProps } from "react-virtualized/dist/commonjs/List";
import { AutoSizer } from "react-virtualized"; import { AutoSizer } from "react-virtualized";
@@ -33,7 +33,7 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
const totalRows = vm.members.length; const totalRows = vm.members.length;
const getRowComponent = (item: MemberWithSeparator): React.JSX.Element => { const getRowComponent = (item: MemberWithSeparator): JSX.Element => {
if (item === SEPARATOR) { if (item === SEPARATOR) {
return <hr className="mx_MemberListView_separator" />; return <hr className="mx_MemberListView_separator" />;
} else if (item.member) { } else if (item.member) {
@@ -64,7 +64,7 @@ const MemberListView: React.FC<IProps> = (props: IProps) => {
} }
}; };
const rowRenderer = ({ key, index, style }: ListRowProps): React.JSX.Element => { const rowRenderer = ({ key, index, style }: ListRowProps): JSX.Element => {
if (index === totalRows) { if (index === totalRows) {
// We've rendered all the members, // We've rendered all the members,
// now we render an empty div to add some space to the end of the list. // now we render an empty div to add some space to the end of the list.

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; import React, { type JSX } from "react";
import { Tooltip } from "@vector-im/compound-web"; import { Tooltip } from "@vector-im/compound-web";
import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified"; import VerifiedIcon from "@vector-im/compound-design-tokens/assets/web/icons/verified";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid"; import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error-solid";
@@ -14,7 +14,7 @@ import { _t } from "../../../../../../languageHandler";
import { E2EStatus } from "../../../../../../utils/ShieldUtils"; import { E2EStatus } from "../../../../../../utils/ShieldUtils";
import { crossSigningUserTitles } from "../../../E2EIcon"; import { crossSigningUserTitles } from "../../../E2EIcon";
function getIconFromStatus(status: E2EStatus): React.JSX.Element | undefined { function getIconFromStatus(status: E2EStatus): JSX.Element | undefined {
switch (status) { switch (status) {
case E2EStatus.Normal: case E2EStatus.Normal:
return undefined; return undefined;

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; import React, { type JSX } from "react";
import OnlineOrUnavailableIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-solid-8x8"; 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 OfflineIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-outline-8x8";
import DNDIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-strikethrough-8x8"; import DNDIcon from "@vector-im/compound-design-tokens/assets/web/icons/presence-strikethrough-8x8";
@@ -19,7 +19,7 @@ interface Props {
export const BUSY_PRESENCE_NAME = new UnstableValue("busy", "org.matrix.msc3026.busy"); export const BUSY_PRESENCE_NAME = new UnstableValue("busy", "org.matrix.msc3026.busy");
function getIconForPresenceState(state: string): React.JSX.Element { function getIconForPresenceState(state: string): JSX.Element {
switch (state) { switch (state) {
case "online": case "online":
return <OnlineOrUnavailableIcon height="8px" width="8px" className="mx_PresenceIconView_online" />; return <OnlineOrUnavailableIcon height="8px" width="8px" className="mx_PresenceIconView_online" />;

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { forwardRef } from "react"; import React, { type Ref, type JSX, type ReactNode } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { formatCount } from "../../../../utils/FormattingUtils"; import { formatCount } from "../../../../utils/FormattingUtils";
@@ -26,6 +26,8 @@ interface Props {
* for the difference between the two. * for the difference between the two.
*/ */
forceDot?: boolean; forceDot?: boolean;
children?: ReactNode;
ref?: Ref<HTMLDivElement>;
} }
interface ClickableProps extends Props { interface ClickableProps extends Props {
@@ -45,61 +47,66 @@ interface ClickableProps extends Props {
* notifications in the room list, it may have a green badge with the number of unread notifications, * notifications in the room list, it may have a green badge with the number of unread notifications,
* but somewhere else it may just have a green dot as a more compact representation of the same information. * but somewhere else it may just have a green dot as a more compact representation of the same information.
*/ */
export const StatelessNotificationBadge = forwardRef<HTMLDivElement, XOR<Props, ClickableProps>>( export const StatelessNotificationBadge = ({
({ symbol, count, level, knocked, forceDot = false, ...props }, ref) => { symbol,
const hideBold = useSettingValue("feature_hidebold"); count,
level,
knocked,
forceDot = false,
...props
}: XOR<Props, ClickableProps>): JSX.Element => {
const hideBold = useSettingValue("feature_hidebold");
// Don't show a badge if we don't need to // Don't show a badge if we don't need to
if ((level === NotificationLevel.None || (hideBold && level == NotificationLevel.Activity)) && !knocked) { if ((level === NotificationLevel.None || (hideBold && level == NotificationLevel.Activity)) && !knocked) {
return <></>; return <></>;
} }
const hasUnreadCount = level >= NotificationLevel.Notification && (!!count || !!symbol); const hasUnreadCount = level >= NotificationLevel.Notification && (!!count || !!symbol);
const isEmptyBadge = symbol === null && count === 0; const isEmptyBadge = symbol === null && count === 0;
if (symbol === null && count > 0) { if (symbol === null && count > 0) {
symbol = formatCount(count); symbol = formatCount(count);
} }
// We show a dot if either: // We show a dot if either:
// * The props force us to, or // * The props force us to, or
// * It's just an activity-level notification or (in theory) lower and the room isn't knocked // * It's just an activity-level notification or (in theory) lower and the room isn't knocked
const badgeType = const badgeType =
forceDot || (level <= NotificationLevel.Activity && !knocked) forceDot || (level <= NotificationLevel.Activity && !knocked)
? "dot" ? "dot"
: !symbol || symbol.length < 3 : !symbol || symbol.length < 3
? "badge_2char" ? "badge_2char"
: "badge_3char"; : "badge_3char";
const classes = classNames({ const classes = classNames({
"mx_NotificationBadge": true, "mx_NotificationBadge": true,
"mx_NotificationBadge_visible": isEmptyBadge || knocked ? true : hasUnreadCount, "mx_NotificationBadge_visible": isEmptyBadge || knocked ? true : hasUnreadCount,
"mx_NotificationBadge_level_notification": level == NotificationLevel.Notification, "mx_NotificationBadge_level_notification": level == NotificationLevel.Notification,
"mx_NotificationBadge_level_highlight": level >= NotificationLevel.Highlight, "mx_NotificationBadge_level_highlight": level >= NotificationLevel.Highlight,
"mx_NotificationBadge_knocked": knocked, "mx_NotificationBadge_knocked": knocked,
// Exactly one of mx_NotificationBadge_dot, mx_NotificationBadge_2char, mx_NotificationBadge_3char // Exactly one of mx_NotificationBadge_dot, mx_NotificationBadge_2char, mx_NotificationBadge_3char
"mx_NotificationBadge_dot": badgeType === "dot", "mx_NotificationBadge_dot": badgeType === "dot",
"mx_NotificationBadge_2char": badgeType === "badge_2char", "mx_NotificationBadge_2char": badgeType === "badge_2char",
"mx_NotificationBadge_3char": badgeType === "badge_3char", "mx_NotificationBadge_3char": badgeType === "badge_3char",
// Badges with text should always use light colors // Badges with text should always use light colors
"cpd-theme-light": badgeType !== "dot", "cpd-theme-light": badgeType !== "dot",
}); });
if (props.onClick) {
return (
<AccessibleButton {...props} className={classes} onClick={props.onClick} ref={ref}>
<span className="mx_NotificationBadge_count">{symbol}</span>
{props.children}
</AccessibleButton>
);
}
if (props.onClick) {
return ( return (
<div className={classes} ref={ref}> <AccessibleButton {...props} className={classes} onClick={props.onClick} ref={props.ref}>
<span className="mx_NotificationBadge_count">{symbol}</span> <span className="mx_NotificationBadge_count">{symbol}</span>
</div> {props.children}
</AccessibleButton>
); );
}, }
);
return (
<div className={classes} ref={props.ref}>
<span className="mx_NotificationBadge_count">{symbol}</span>
</div>
);
};

View File

@@ -5,7 +5,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
import React, { type ComponentProps, forwardRef, type JSX, useState } from "react"; import React, { type ComponentProps, type JSX, type Ref, useState } from "react";
import { IconButton, Menu, MenuItem, Separator, ToggleMenuItem, Tooltip } from "@vector-im/compound-web"; import { IconButton, Menu, MenuItem, Separator, ToggleMenuItem, Tooltip } from "@vector-im/compound-web";
import MarkAsReadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-read"; import MarkAsReadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-read";
import MarkAsUnreadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-unread"; import MarkAsUnreadIcon from "@vector-im/compound-design-tokens/assets/web/icons/mark-as-unread";
@@ -147,22 +147,22 @@ function MoreOptionsMenu({ vm, setMenuOpen }: MoreOptionsMenuProps): JSX.Element
); );
} }
interface MoreOptionsButtonProps extends ComponentProps<typeof IconButton> {} interface MoreOptionsButtonProps extends ComponentProps<typeof IconButton> {
ref?: Ref<HTMLButtonElement>;
}
/** /**
* A button to trigger the more options menu. * A button to trigger the more options menu.
*/ */
export const MoreOptionsButton = forwardRef<HTMLButtonElement, MoreOptionsButtonProps>( export const MoreOptionsButton = function MoreOptionsButton(props: MoreOptionsButtonProps): JSX.Element {
function MoreOptionsButton(props, ref) { return (
return ( <Tooltip label={_t("room_list|room|more_options")}>
<Tooltip label={_t("room_list|room|more_options")}> <IconButton aria-label={_t("room_list|room|more_options")} {...props}>
<IconButton aria-label={_t("room_list|room|more_options")} {...props} ref={ref}> <OverflowIcon />
<OverflowIcon /> </IconButton>
</IconButton> </Tooltip>
</Tooltip> );
); };
},
);
interface NotificationMenuProps { interface NotificationMenuProps {
/** /**
@@ -238,15 +238,17 @@ interface NotificationButtonProps extends ComponentProps<typeof IconButton> {
* Whether the room is muted. * Whether the room is muted.
*/ */
isRoomMuted: boolean; isRoomMuted: boolean;
ref?: Ref<HTMLButtonElement>;
} }
/** /**
* A button to trigger the notification menu. * A button to trigger the notification menu.
*/ */
export const NotificationButton = forwardRef<HTMLButtonElement, NotificationButtonProps>(function MoreOptionsButton( export const NotificationButton = function MoreOptionsButton({
{ isRoomMuted, ...props }, isRoomMuted,
ref, ref,
) { ...props
}: NotificationButtonProps): JSX.Element {
return ( return (
<Tooltip label={_t("room_list|notification_options")}> <Tooltip label={_t("room_list|notification_options")}>
<IconButton aria-label={_t("room_list|notification_options")} {...props} ref={ref}> <IconButton aria-label={_t("room_list|notification_options")} {...props} ref={ref}>
@@ -254,4 +256,4 @@ export const NotificationButton = forwardRef<HTMLButtonElement, NotificationButt
</IconButton> </IconButton>
</Tooltip> </Tooltip>
); );
}); };

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, type ForwardedRef, forwardRef, type RefObject, useMemo } from "react"; import React, { type JSX, type RefObject, useMemo, type ReactNode } from "react";
import classNames from "classnames"; import classNames from "classnames";
import type EditorStateTransfer from "../../../../utils/EditorStateTransfer"; import type EditorStateTransfer from "../../../../utils/EditorStateTransfer";
@@ -21,15 +21,13 @@ import { type ComposerFunctions } from "./types";
interface ContentProps { interface ContentProps {
disabled?: boolean; disabled?: boolean;
composerFunctions: ComposerFunctions; composerFunctions: ComposerFunctions;
ref?: RefObject<HTMLElement | null>;
} }
const Content = forwardRef<HTMLElement, ContentProps>(function Content( const Content = function Content({ disabled = false, composerFunctions, ref }: ContentProps): ReactNode {
{ disabled = false, composerFunctions }: ContentProps, useWysiwygEditActionHandler(disabled, ref, composerFunctions);
forwardRef: ForwardedRef<HTMLElement>,
) {
useWysiwygEditActionHandler(disabled, forwardRef as RefObject<HTMLElement>, composerFunctions);
return null; return null;
}); };
interface EditWysiwygComposerProps { interface EditWysiwygComposerProps {
disabled?: boolean; disabled?: boolean;

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, type ForwardedRef, forwardRef, type RefObject, useMemo } from "react"; import React, { type JSX, type RefObject, useMemo, type ReactNode } from "react";
import { type IEventRelation } from "matrix-js-sdk/src/matrix"; import { type IEventRelation } from "matrix-js-sdk/src/matrix";
import { useWysiwygSendActionHandler } from "./hooks/useWysiwygSendActionHandler"; import { useWysiwygSendActionHandler } from "./hooks/useWysiwygSendActionHandler";
@@ -22,15 +22,13 @@ import { ComposerContext, getDefaultContextValue } from "./ComposerContext";
interface ContentProps { interface ContentProps {
disabled?: boolean; disabled?: boolean;
composerFunctions: ComposerFunctions; composerFunctions: ComposerFunctions;
ref?: RefObject<HTMLElement | null>;
} }
const Content = forwardRef<HTMLElement, ContentProps>(function Content( const Content = function Content({ disabled = false, composerFunctions, ref }: ContentProps): ReactNode {
{ disabled = false, composerFunctions }: ContentProps, useWysiwygSendActionHandler(disabled, ref, composerFunctions);
forwardRef: ForwardedRef<HTMLElement>,
) {
useWysiwygSendActionHandler(disabled, forwardRef as RefObject<HTMLElement>, composerFunctions);
return null; return null;
}); };
export interface SendWysiwygComposerProps { export interface SendWysiwygComposerProps {
initialContent?: string; initialContent?: string;

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/ */
import classNames from "classnames"; import classNames from "classnames";
import React, { type CSSProperties, forwardRef, memo, type RefObject, type ReactNode } from "react"; import React, { type CSSProperties, memo, type RefObject, type ReactNode } from "react";
import { useIsExpanded } from "../hooks/useIsExpanded"; import { useIsExpanded } from "../hooks/useIsExpanded";
import { useSelection } from "../hooks/useSelection"; import { useSelection } from "../hooks/useSelection";
@@ -19,44 +19,36 @@ interface EditorProps {
placeholder?: string; placeholder?: string;
leftComponent?: ReactNode; leftComponent?: ReactNode;
rightComponent?: ReactNode; rightComponent?: ReactNode;
ref?: RefObject<HTMLDivElement | null>;
} }
export const Editor = memo( export const Editor = memo(function Editor({ disabled, placeholder, leftComponent, rightComponent, ref }: EditorProps) {
forwardRef<HTMLDivElement | null, EditorProps>(function Editor( const isExpanded = useIsExpanded(ref, HEIGHT_BREAKING_POINT);
{ disabled, placeholder, leftComponent, rightComponent }: EditorProps, const { onFocus, onBlur, onInput } = useSelection();
ref,
) {
const isExpanded = useIsExpanded(ref as RefObject<HTMLDivElement | null>, HEIGHT_BREAKING_POINT);
const { onFocus, onBlur, onInput } = useSelection();
return ( return (
<div <div data-testid="WysiwygComposerEditor" className="mx_WysiwygComposer_Editor" data-is-expanded={isExpanded}>
data-testid="WysiwygComposerEditor" {leftComponent}
className="mx_WysiwygComposer_Editor" <div className="mx_WysiwygComposer_Editor_container">
data-is-expanded={isExpanded} <div
> className={classNames("mx_WysiwygComposer_Editor_content", {
{leftComponent} mx_WysiwygComposer_Editor_content_placeholder: Boolean(placeholder),
<div className="mx_WysiwygComposer_Editor_container"> })}
<div style={{ "--placeholder": `"${placeholder}"` } as CSSProperties}
className={classNames("mx_WysiwygComposer_Editor_content", { ref={ref}
mx_WysiwygComposer_Editor_content_placeholder: Boolean(placeholder), contentEditable={!disabled}
})} role="textbox"
style={{ "--placeholder": `"${placeholder}"` } as CSSProperties} aria-multiline="true"
ref={ref} aria-autocomplete="list"
contentEditable={!disabled} aria-haspopup="listbox"
role="textbox" dir="auto"
aria-multiline="true" aria-disabled={disabled}
aria-autocomplete="list" onFocus={onFocus}
aria-haspopup="listbox" onBlur={onBlur}
dir="auto" onInput={onInput}
aria-disabled={disabled} />
onFocus={onFocus}
onBlur={onBlur}
onInput={onInput}
/>
</div>
{rightComponent}
</div> </div>
); {rightComponent}
}), </div>
); );
});

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type JSX, type ForwardedRef, forwardRef, type FunctionComponent } from "react"; import React, { type JSX, type Ref, type FunctionComponent } from "react";
import { type FormattingFunctions, type MappedSuggestion } from "@vector-im/matrix-wysiwyg"; import { type FormattingFunctions, type MappedSuggestion } from "@vector-im/matrix-wysiwyg";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
@@ -39,6 +39,8 @@ interface WysiwygAutocompleteProps {
* Handler purely for the at-room mentions special case * Handler purely for the at-room mentions special case
*/ */
handleAtRoomMention: FormattingFunctions["mentionAtRoom"]; handleAtRoomMention: FormattingFunctions["mentionAtRoom"];
ref?: Ref<Autocomplete>;
} }
/** /**
@@ -48,69 +50,70 @@ interface WysiwygAutocompleteProps {
* *
* @param props.ref - the ref will be attached to the rendered `<Autocomplete />` component * @param props.ref - the ref will be attached to the rendered `<Autocomplete />` component
*/ */
const WysiwygAutocomplete = forwardRef( const WysiwygAutocomplete = ({
( suggestion,
{ suggestion, handleMention, handleCommand, handleAtRoomMention }: WysiwygAutocompleteProps, handleMention,
ref: ForwardedRef<Autocomplete>, handleCommand,
): JSX.Element | null => { handleAtRoomMention,
const { room } = useScopedRoomContext("room"); ref,
const client = useMatrixClientContext(); }: WysiwygAutocompleteProps): JSX.Element | null => {
const { room } = useScopedRoomContext("room");
const client = useMatrixClientContext();
function handleConfirm(completion: ICompletion): void { function handleConfirm(completion: ICompletion): void {
if (client === undefined || room === undefined) { if (client === undefined || room === undefined) {
return; return;
}
switch (completion.type) {
case "command": {
// TODO determine if utils in SlashCommands.tsx are required.
// Trim the completion as some include trailing spaces, but we always insert a
// trailing space in the rust model anyway
handleCommand(completion.completion.trim());
return;
}
case "at-room": {
handleAtRoomMention(getMentionAttributes(completion, client, room));
return;
}
case "room":
case "user": {
if (typeof completion.href === "string") {
handleMention(
completion.href,
getMentionDisplayText(completion, client),
getMentionAttributes(completion, client, room),
);
}
return;
}
// TODO - handle "community" type
default:
return;
}
} }
if (!room) return null; switch (completion.type) {
case "command": {
// TODO determine if utils in SlashCommands.tsx are required.
// Trim the completion as some include trailing spaces, but we always insert a
// trailing space in the rust model anyway
handleCommand(completion.completion.trim());
return;
}
case "at-room": {
handleAtRoomMention(getMentionAttributes(completion, client, room));
return;
}
case "room":
case "user": {
if (typeof completion.href === "string") {
handleMention(
completion.href,
getMentionDisplayText(completion, client),
getMentionAttributes(completion, client, room),
);
}
return;
}
// TODO - handle "community" type
default:
return;
}
}
const autoCompleteQuery = buildQuery(suggestion); if (!room) return null;
// debug for https://github.com/vector-im/element-web/issues/26037
logger.log(`## 26037 ## Rendering Autocomplete for WysiwygAutocomplete with query: "${autoCompleteQuery}"`);
// TODO - determine if we show all of the /command suggestions, there are some options in the const autoCompleteQuery = buildQuery(suggestion);
// list which don't seem to make sense in this context, specifically /html and /plain // debug for https://github.com/vector-im/element-web/issues/26037
return ( logger.log(`## 26037 ## Rendering Autocomplete for WysiwygAutocomplete with query: "${autoCompleteQuery}"`);
<div className="mx_WysiwygComposer_AutoCompleteWrapper" data-testid="autocomplete-wrapper">
<Autocomplete // TODO - determine if we show all of the /command suggestions, there are some options in the
ref={ref} // list which don't seem to make sense in this context, specifically /html and /plain
query={autoCompleteQuery} return (
onConfirm={handleConfirm} <div className="mx_WysiwygComposer_AutoCompleteWrapper" data-testid="autocomplete-wrapper">
selection={{ start: 0, end: 0 }} <Autocomplete
room={room} ref={ref}
/> query={autoCompleteQuery}
</div> onConfirm={handleConfirm}
); selection={{ start: 0, end: 0 }}
}, room={room}
); />
</div>
);
};
(WysiwygAutocomplete as FunctionComponent).displayName = "WysiwygAutocomplete"; (WysiwygAutocomplete as FunctionComponent).displayName = "WysiwygAutocomplete";

View File

@@ -8,10 +8,10 @@ Please see LICENSE files in the repository root for full details.
import { type RefObject, useEffect, useState } from "react"; import { type RefObject, useEffect, useState } from "react";
export function useIsExpanded(ref: RefObject<HTMLElement | null>, breakingPoint: number): boolean { export function useIsExpanded(ref: RefObject<HTMLElement | null> | undefined, breakingPoint: number): boolean {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
useEffect(() => { useEffect(() => {
if (ref.current) { if (ref?.current) {
const editor = ref.current; const editor = ref.current;
const resizeObserver = new ResizeObserver((entries) => { const resizeObserver = new ResizeObserver((entries) => {
requestAnimationFrame(() => { requestAnimationFrame(() => {

View File

@@ -22,7 +22,7 @@ import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.
export function useWysiwygEditActionHandler( export function useWysiwygEditActionHandler(
disabled: boolean, disabled: boolean,
composerElement: RefObject<HTMLElement>, composerElement: RefObject<HTMLElement | null> | undefined,
composerFunctions: ComposerFunctions, composerFunctions: ComposerFunctions,
): void { ): void {
const roomContext = useScopedRoomContext("timelineRenderingType"); const roomContext = useScopedRoomContext("timelineRenderingType");
@@ -33,7 +33,7 @@ export function useWysiwygEditActionHandler(
(payload: ActionPayload) => { (payload: ActionPayload) => {
// don't let the user into the composer if it is disabled - all of these branches lead // don't let the user into the composer if it is disabled - all of these branches lead
// to the cursor being in the composer // to the cursor being in the composer
if (disabled || !composerElement.current) return; if (disabled || !composerElement?.current) return;
const context = payload.context ?? TimelineRenderingType.Room; const context = payload.context ?? TimelineRenderingType.Room;

View File

@@ -22,7 +22,7 @@ import { useScopedRoomContext } from "../../../../../contexts/ScopedRoomContext.
export function useWysiwygSendActionHandler( export function useWysiwygSendActionHandler(
disabled: boolean, disabled: boolean,
composerElement: RefObject<HTMLElement>, composerElement: RefObject<HTMLElement | null> | undefined,
composerFunctions: ComposerFunctions, composerFunctions: ComposerFunctions,
): void { ): void {
const roomContext = useScopedRoomContext("timelineRenderingType"); const roomContext = useScopedRoomContext("timelineRenderingType");

View File

@@ -15,7 +15,7 @@ import AccessibleButton, { type ButtonProps } from "../../elements/AccessibleBut
type Props<T extends keyof HTMLElementTagNameMap> = Omit< type Props<T extends keyof HTMLElementTagNameMap> = Omit<
ButtonProps<T>, ButtonProps<T>,
"aria-label" | "title" | "kind" | "className" | "element" "aria-label" | "title" | "kind" | "className" | "element" | "ref"
> & { > & {
isExpanded: boolean; isExpanded: boolean;
}; };

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type ForwardedRef, forwardRef } from "react"; import React, { type JSX, type Ref } from "react";
import { type IPusher, PUSHER_DEVICE_ID, type LocalNotificationSettings } from "matrix-js-sdk/src/matrix"; import { type IPusher, PUSHER_DEVICE_ID, type LocalNotificationSettings } from "matrix-js-sdk/src/matrix";
import { _t } from "../../../../languageHandler"; import { _t } from "../../../../languageHandler";
@@ -47,6 +47,7 @@ interface Props {
* Changes sign out button to be a manage button * Changes sign out button to be a manage button
*/ */
delegatedAuthAccountUrl?: string; delegatedAuthAccountUrl?: string;
ref?: Ref<HTMLDivElement>;
} }
const isDeviceSelected = ( const isDeviceSelected = (
@@ -237,152 +238,148 @@ const DeviceListItem: React.FC<{
* Filtered list of devices * Filtered list of devices
* Sorted by latest activity descending * Sorted by latest activity descending
*/ */
export const FilteredDeviceList = forwardRef( export const FilteredDeviceList = ({
( devices,
{ pushers,
devices, localNotificationSettings,
pushers, filter,
localNotificationSettings, expandedDeviceIds,
filter, signingOutDeviceIds,
expandedDeviceIds, selectedDeviceIds,
signingOutDeviceIds, onFilterChange,
selectedDeviceIds, onDeviceExpandToggle,
onFilterChange, saveDeviceName,
onDeviceExpandToggle, onSignOutDevices,
saveDeviceName, onRequestDeviceVerification,
onSignOutDevices, setPushNotifications,
onRequestDeviceVerification, setSelectedDeviceIds,
setPushNotifications, supportsMSC3881,
setSelectedDeviceIds, delegatedAuthAccountUrl,
supportsMSC3881, ref,
delegatedAuthAccountUrl, }: Props): JSX.Element => {
}: Props, const sortedDevices = getFilteredSortedDevices(devices, filter);
ref: ForwardedRef<HTMLDivElement>,
) => {
const sortedDevices = getFilteredSortedDevices(devices, filter);
function getPusherForDevice(device: ExtendedDevice): IPusher | undefined { function getPusherForDevice(device: ExtendedDevice): IPusher | undefined {
return pushers.find((pusher) => pusher[PUSHER_DEVICE_ID.name] === device.device_id); return pushers.find((pusher) => pusher[PUSHER_DEVICE_ID.name] === device.device_id);
}
const toggleSelection = (deviceId: ExtendedDevice["device_id"]): void => {
if (isDeviceSelected(deviceId, selectedDeviceIds)) {
// remove from selection
setSelectedDeviceIds(selectedDeviceIds.filter((id) => id !== deviceId));
} else {
setSelectedDeviceIds([...selectedDeviceIds, deviceId]);
} }
};
const toggleSelection = (deviceId: ExtendedDevice["device_id"]): void => { const options: FilterDropdownOption<DeviceFilterKey>[] = [
if (isDeviceSelected(deviceId, selectedDeviceIds)) { { id: ALL_FILTER_ID, label: _t("settings|sessions|filter_all") },
// remove from selection {
setSelectedDeviceIds(selectedDeviceIds.filter((id) => id !== deviceId)); id: DeviceSecurityVariation.Verified,
} else { label: _t("common|verified"),
setSelectedDeviceIds([...selectedDeviceIds, deviceId]); description: _t("settings|sessions|filter_verified_description"),
} },
}; {
id: DeviceSecurityVariation.Unverified,
label: _t("common|unverified"),
description: _t("settings|sessions|filter_unverified_description"),
},
{
id: DeviceSecurityVariation.Inactive,
label: _t("settings|sessions|filter_inactive"),
description: _t("settings|sessions|filter_inactive_description", {
inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS,
}),
},
];
const options: FilterDropdownOption<DeviceFilterKey>[] = [ const onFilterOptionChange = (filterId: DeviceFilterKey): void => {
{ id: ALL_FILTER_ID, label: _t("settings|sessions|filter_all") }, onFilterChange(filterId === ALL_FILTER_ID ? undefined : (filterId as FilterVariation));
{ };
id: DeviceSecurityVariation.Verified,
label: _t("common|verified"),
description: _t("settings|sessions|filter_verified_description"),
},
{
id: DeviceSecurityVariation.Unverified,
label: _t("common|unverified"),
description: _t("settings|sessions|filter_unverified_description"),
},
{
id: DeviceSecurityVariation.Inactive,
label: _t("settings|sessions|filter_inactive"),
description: _t("settings|sessions|filter_inactive_description", {
inactiveAgeDays: INACTIVE_DEVICE_AGE_DAYS,
}),
},
];
const onFilterOptionChange = (filterId: DeviceFilterKey): void => { const isAllSelected = selectedDeviceIds.length >= sortedDevices.length;
onFilterChange(filterId === ALL_FILTER_ID ? undefined : (filterId as FilterVariation)); const toggleSelectAll = (): void => {
}; if (isAllSelected) {
setSelectedDeviceIds([]);
} else {
setSelectedDeviceIds(sortedDevices.map((device) => device.device_id));
}
};
const isAllSelected = selectedDeviceIds.length >= sortedDevices.length; const isSigningOut = !!signingOutDeviceIds.length;
const toggleSelectAll = (): void => {
if (isAllSelected) {
setSelectedDeviceIds([]);
} else {
setSelectedDeviceIds(sortedDevices.map((device) => device.device_id));
}
};
const isSigningOut = !!signingOutDeviceIds.length; return (
<div className="mx_FilteredDeviceList" ref={ref}>
return ( <FilteredDeviceListHeader
<div className="mx_FilteredDeviceList" ref={ref}> selectedDeviceCount={selectedDeviceIds.length}
<FilteredDeviceListHeader isAllSelected={isAllSelected}
selectedDeviceCount={selectedDeviceIds.length} toggleSelectAll={toggleSelectAll}
isAllSelected={isAllSelected} isSelectDisabled={!!delegatedAuthAccountUrl}
toggleSelectAll={toggleSelectAll} >
isSelectDisabled={!!delegatedAuthAccountUrl} {selectedDeviceIds.length ? (
> <>
{selectedDeviceIds.length ? ( <AccessibleButton
<> data-testid="sign-out-selection-cta"
<AccessibleButton kind="danger_inline"
data-testid="sign-out-selection-cta" disabled={isSigningOut}
kind="danger_inline" onClick={() => onSignOutDevices(selectedDeviceIds)}
disabled={isSigningOut} className="mx_FilteredDeviceList_headerButton"
onClick={() => onSignOutDevices(selectedDeviceIds)} >
className="mx_FilteredDeviceList_headerButton" {isSigningOut && <Spinner w={16} h={16} />}
> {_t("action|sign_out")}
{isSigningOut && <Spinner w={16} h={16} />} </AccessibleButton>
{_t("action|sign_out")} <AccessibleButton
</AccessibleButton> data-testid="cancel-selection-cta"
<AccessibleButton kind="content_inline"
data-testid="cancel-selection-cta" disabled={isSigningOut}
kind="content_inline" onClick={() => setSelectedDeviceIds([])}
disabled={isSigningOut} className="mx_FilteredDeviceList_headerButton"
onClick={() => setSelectedDeviceIds([])} >
className="mx_FilteredDeviceList_headerButton" {_t("action|cancel")}
> </AccessibleButton>
{_t("action|cancel")} </>
</AccessibleButton>
</>
) : (
<FilterDropdown<DeviceFilterKey>
id="device-list-filter"
label={_t("settings|sessions|filter_label")}
value={filter || ALL_FILTER_ID}
onOptionChange={onFilterOptionChange}
options={options}
selectedLabel={_t("action|show")}
/>
)}
</FilteredDeviceListHeader>
{!!sortedDevices.length ? (
<FilterSecurityCard filter={filter} />
) : ( ) : (
<NoResults filter={filter} clearFilter={() => onFilterChange(undefined)} /> <FilterDropdown<DeviceFilterKey>
id="device-list-filter"
label={_t("settings|sessions|filter_label")}
value={filter || ALL_FILTER_ID}
onOptionChange={onFilterOptionChange}
options={options}
selectedLabel={_t("action|show")}
/>
)} )}
<ol className="mx_FilteredDeviceList_list"> </FilteredDeviceListHeader>
{sortedDevices.map((device) => ( {!!sortedDevices.length ? (
<DeviceListItem <FilterSecurityCard filter={filter} />
key={device.device_id} ) : (
device={device} <NoResults filter={filter} clearFilter={() => onFilterChange(undefined)} />
pusher={getPusherForDevice(device)} )}
localNotificationSettings={localNotificationSettings.get(device.device_id)} <ol className="mx_FilteredDeviceList_list">
isExpanded={expandedDeviceIds.includes(device.device_id)} {sortedDevices.map((device) => (
isSigningOut={signingOutDeviceIds.includes(device.device_id)} <DeviceListItem
isSelected={isDeviceSelected(device.device_id, selectedDeviceIds)} key={device.device_id}
isSelectDisabled={!!delegatedAuthAccountUrl} device={device}
onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)} pusher={getPusherForDevice(device)}
onSignOutDevice={() => onSignOutDevices([device.device_id])} localNotificationSettings={localNotificationSettings.get(device.device_id)}
saveDeviceName={(deviceName: string) => saveDeviceName(device.device_id, deviceName)} isExpanded={expandedDeviceIds.includes(device.device_id)}
onRequestDeviceVerification={ isSigningOut={signingOutDeviceIds.includes(device.device_id)}
onRequestDeviceVerification isSelected={isDeviceSelected(device.device_id, selectedDeviceIds)}
? () => onRequestDeviceVerification(device.device_id) isSelectDisabled={!!delegatedAuthAccountUrl}
: undefined onDeviceExpandToggle={() => onDeviceExpandToggle(device.device_id)}
} onSignOutDevice={() => onSignOutDevices([device.device_id])}
setPushNotifications={setPushNotifications} saveDeviceName={(deviceName: string) => saveDeviceName(device.device_id, deviceName)}
toggleSelected={() => toggleSelection(device.device_id)} onRequestDeviceVerification={
supportsMSC3881={supportsMSC3881} onRequestDeviceVerification
delegatedAuthAccountUrl={delegatedAuthAccountUrl} ? () => onRequestDeviceVerification(device.device_id)
/> : undefined
))} }
</ol> setPushNotifications={setPushNotifications}
</div> toggleSelected={() => toggleSelection(device.device_id)}
); supportsMSC3881={supportsMSC3881}
}, delegatedAuthAccountUrl={delegatedAuthAccountUrl}
); />
))}
</ol>
</div>
);
};

View File

@@ -45,7 +45,7 @@ import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
type ButtonProps<T extends keyof HTMLElementTagNameMap> = Omit< type ButtonProps<T extends keyof HTMLElementTagNameMap> = Omit<
AccessibleButtonProps<T>, AccessibleButtonProps<T>,
"title" | "onClick" | "size" | "element" "title" | "onClick" | "size" | "element" | "ref"
> & { > & {
space?: Room; space?: Room;
spaceKey?: SpaceKey; spaceKey?: SpaceKey;

View File

@@ -6,7 +6,7 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
import React, { type ComponentProps, forwardRef } from "react"; import React, { type ComponentProps, type Ref, type JSX } from "react";
import ThreadsSolidIcon from "@vector-im/compound-design-tokens/assets/web/icons/threads-solid"; import ThreadsSolidIcon from "@vector-im/compound-design-tokens/assets/web/icons/threads-solid";
import classNames from "classnames"; import classNames from "classnames";
import { IconButton, Text, Tooltip } from "@vector-im/compound-web"; import { IconButton, Text, Tooltip } from "@vector-im/compound-web";
@@ -28,44 +28,46 @@ interface ThreadsActivityCentreButtonProps extends ComponentProps<typeof IconBut
* The notification level of the threads. * The notification level of the threads.
*/ */
notificationLevel: NotificationLevel; notificationLevel: NotificationLevel;
ref?: Ref<HTMLButtonElement>;
} }
/** /**
* A button to open the thread activity centre. * A button to open the thread activity centre.
*/ */
export const ThreadsActivityCentreButton = forwardRef<HTMLButtonElement, ThreadsActivityCentreButtonProps>( export const ThreadsActivityCentreButton = function ThreadsActivityCentreButton({
function ThreadsActivityCentreButton( displayLabel,
{ displayLabel, notificationLevel, disableTooltip, ...props }, notificationLevel,
ref, disableTooltip,
): React.JSX.Element { ref,
// Disable tooltip when the label is displayed ...props
const openTooltip = disableTooltip || displayLabel ? false : undefined; }: ThreadsActivityCentreButtonProps): JSX.Element {
// Disable tooltip when the label is displayed
const openTooltip = disableTooltip || displayLabel ? false : undefined;
return ( return (
<Tooltip label={_t("common|threads")} placement="right" open={openTooltip}> <Tooltip label={_t("common|threads")} placement="right" open={openTooltip}>
<IconButton <IconButton
aria-label={_t("common|threads")} aria-label={_t("common|threads")}
className={classNames("mx_ThreadsActivityCentreButton", { expanded: displayLabel })} className={classNames("mx_ThreadsActivityCentreButton", { expanded: displayLabel })}
indicator={notificationLevelToIndicator(notificationLevel)} indicator={notificationLevelToIndicator(notificationLevel)}
{...props} {...props}
ref={ref} ref={ref}
> >
<> <>
<ThreadsSolidIcon className="mx_ThreadsActivityCentreButton_Icon" /> <ThreadsSolidIcon className="mx_ThreadsActivityCentreButton_Icon" />
{/* This is dirty, but we need to add the label to the indicator icon */} {/* This is dirty, but we need to add the label to the indicator icon */}
{displayLabel && ( {displayLabel && (
<Text <Text
className="mx_ThreadsActivityCentreButton_Text" className="mx_ThreadsActivityCentreButton_Text"
as="span" as="span"
size="md" size="md"
title={_t("common|threads")} title={_t("common|threads")}
> >
{_t("common|threads")} {_t("common|threads")}
</Text> </Text>
)} )}
</> </>
</IconButton> </IconButton>
</Tooltip> </Tooltip>
); );
}, };
);

View File

@@ -8,7 +8,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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { createRef, useState, forwardRef } from "react"; import React, { createRef, useState, type Ref, type FC } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { type MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { type MatrixCall } from "matrix-js-sdk/src/webrtc/call";
@@ -41,31 +41,40 @@ type ButtonProps = Omit<AccessibleButtonProps<"div">, "title" | "element"> & {
offLabel?: string; offLabel?: string;
forceHide?: boolean; forceHide?: boolean;
onHover?: (hovering: boolean) => void; onHover?: (hovering: boolean) => void;
ref?: Ref<HTMLElement>;
}; };
const LegacyCallViewToggleButton = forwardRef<HTMLElement, ButtonProps>( const LegacyCallViewToggleButton: FC<ButtonProps> = ({
({ children, state: isOn, className, onLabel, offLabel, forceHide, onHover, ...props }, ref) => { children,
const classes = classNames("mx_LegacyCallViewButtons_button", className, { state: isOn,
mx_LegacyCallViewButtons_button_on: isOn, className,
mx_LegacyCallViewButtons_button_off: !isOn, onLabel,
}); offLabel,
forceHide,
onHover,
ref,
...props
}) => {
const classes = classNames("mx_LegacyCallViewButtons_button", className, {
mx_LegacyCallViewButtons_button_on: isOn,
mx_LegacyCallViewButtons_button_off: !isOn,
});
const title = forceHide ? undefined : isOn ? onLabel : offLabel; const title = forceHide ? undefined : isOn ? onLabel : offLabel;
return ( return (
<AccessibleButton <AccessibleButton
ref={ref} ref={ref}
className={classes} className={classes}
title={title} title={title}
placement="top" placement="top"
onTooltipOpenChange={onHover} onTooltipOpenChange={onHover}
{...props} {...props}
> >
{children} {children}
</AccessibleButton> </AccessibleButton>
); );
}, };
);
interface IDropdownButtonProps extends ButtonProps { interface IDropdownButtonProps extends ButtonProps {
deviceKinds: MediaDeviceKindEnum[]; deviceKinds: MediaDeviceKindEnum[];

View File

@@ -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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type ComponentClass, createContext, forwardRef, useContext } from "react"; import React, { type ComponentClass, createContext, useContext } from "react";
import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { type MatrixClient } from "matrix-js-sdk/src/matrix";
// This context is available to components under LoggedInView, // This context is available to components under LoggedInView,
@@ -24,22 +24,16 @@ export function useMatrixClientContext(): MatrixClient {
return useContext(MatrixClientContext); return useContext(MatrixClientContext);
} }
const matrixHOC = <ComposedComponentProps extends object>( const matrixHOC =
ComposedComponent: ComponentClass<ComposedComponentProps>, <ComposedComponentProps extends object>(
): (( ComposedComponent: ComponentClass<ComposedComponentProps>,
props: Omit<ComposedComponentProps, "mxClient"> & React.RefAttributes<InstanceType<typeof ComposedComponent>>, ): ((
) => React.ReactElement | null) => { props: Omit<ComposedComponentProps, "mxClient"> & React.RefAttributes<InstanceType<typeof ComposedComponent>>,
type ComposedComponentInstance = InstanceType<typeof ComposedComponent>; ) => React.ReactElement | null) =>
(props) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const TypedComponent = ComposedComponent;
return forwardRef<ComposedComponentInstance, Omit<ComposedComponentProps, "mxClient">>((props, ref) => {
const client = useContext(MatrixClientContext); const client = useContext(MatrixClientContext);
// @ts-ignore // @ts-ignore
return <TypedComponent ref={ref} {...props} mxClient={client} />; return <ComposedComponent {...props} mxClient={client} />;
}); };
};
export const withMatrixClientHOC = matrixHOC; export const withMatrixClientHOC = matrixHOC;

View File

@@ -69,7 +69,7 @@ export interface EventTileTypeProps
} }
type FactoryProps = Omit<EventTileTypeProps, "ref">; type FactoryProps = Omit<EventTileTypeProps, "ref">;
type Factory<X = FactoryProps> = (ref: Optional<React.RefObject<any>>, props: X) => JSX.Element; type Factory<X = FactoryProps> = (ref: React.RefObject<any> | undefined, props: X) => JSX.Element;
export const MessageEventFactory: Factory = (ref, props) => <MessageEvent ref={ref} {...props} />; export const MessageEventFactory: Factory = (ref, props) => <MessageEvent ref={ref} {...props} />;
const LegacyCallEventFactory: Factory<FactoryProps & { callEventGrouper: LegacyCallEventGrouper }> = (ref, props) => ( const LegacyCallEventFactory: Factory<FactoryProps & { callEventGrouper: LegacyCallEventGrouper }> = (ref, props) => (

View File

@@ -11,8 +11,8 @@ import { act, fireEvent, screen, waitFor } from "jest-matrix-react";
import { RoomMember, User, RoomEvent } from "matrix-js-sdk/src/matrix"; import { RoomMember, User, RoomEvent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types"; import { KnownMembership } from "matrix-js-sdk/src/types";
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import { type JSX } from "react";
import type React from "react";
import { shouldShowComponent } from "../../../../../../src/customisations/helpers/UIComponents"; import { shouldShowComponent } from "../../../../../../src/customisations/helpers/UIComponents";
import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher"; import defaultDispatcher from "../../../../../../src/dispatcher/dispatcher";
import { type Rendered, renderMemberList } from "./common"; import { type Rendered, renderMemberList } from "./common";
@@ -21,7 +21,7 @@ jest.mock("../../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(), shouldShowComponent: jest.fn(),
})); }));
type Children = (args: { height: number; width: number }) => React.JSX.Element; type Children = (args: { height: number; width: number }) => JSX.Element;
jest.mock("react-virtualized", () => { jest.mock("react-virtualized", () => {
const ReactVirtualized = jest.requireActual("react-virtualized"); const ReactVirtualized = jest.requireActual("react-virtualized");
return { return {

View File

@@ -10,8 +10,8 @@ Please see LICENSE files in the repository root for full details.
import { act } from "react"; import { act } from "react";
import { waitFor } from "jest-matrix-react"; import { waitFor } from "jest-matrix-react";
import { type Room, type RoomMember, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { type Room, type RoomMember, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { type JSX } from "react";
import type React from "react";
import { filterConsole } from "../../../../../test-utils"; import { filterConsole } from "../../../../../test-utils";
import { type Rendered, renderMemberList } from "./common"; import { type Rendered, renderMemberList } from "./common";
@@ -19,7 +19,7 @@ jest.mock("../../../../../../src/customisations/helpers/UIComponents", () => ({
shouldShowComponent: jest.fn(), shouldShowComponent: jest.fn(),
})); }));
type Children = (args: { height: number; width: number }) => React.JSX.Element; type Children = (args: { height: number; width: number }) => JSX.Element;
jest.mock("react-virtualized", () => { jest.mock("react-virtualized", () => {
const ReactVirtualized = jest.requireActual("react-virtualized"); const ReactVirtualized = jest.requireActual("react-virtualized");
return { return {