Merge branch 'develop' into johannes/latest-room-in-space

This commit is contained in:
Johannes Marbach 2023-02-01 19:54:40 +01:00
commit 3766b39361
119 changed files with 4636 additions and 1409 deletions

View file

@ -138,6 +138,7 @@ import { cleanUpBroadcasts, VoiceBroadcastResumer } from "../../voice-broadcast"
import GenericToast from "../views/toasts/GenericToast";
import { Linkify } from "../views/elements/Linkify";
import RovingSpotlightDialog, { Filter } from "../views/dialogs/spotlight/SpotlightDialog";
import { findDMForUser } from "../../utils/dm/findDMForUser";
// legacy export
export { default as Views } from "../../Views";
@ -1101,13 +1102,12 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// TODO: Immutable DMs replaces this
const client = MatrixClientPeg.get();
const dmRoomMap = new DMRoomMap(client);
const dmRooms = dmRoomMap.getDMRoomsForUserId(userId);
const dmRoom = findDMForUser(client, userId);
if (dmRooms.length > 0) {
if (dmRoom) {
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: dmRooms[0],
room_id: dmRoom.roomId,
metricsTrigger: "MessageUser",
});
} else {

View file

@ -72,7 +72,7 @@ const groupedStateEvents = [
// check if there is a previous event and it has the same sender as this event
// and the types are the same/is in continuedTypes and the time between them is <= CONTINUATION_MAX_INTERVAL
export function shouldFormContinuation(
prevEvent: MatrixEvent,
prevEvent: MatrixEvent | null,
mxEvent: MatrixEvent,
showHiddenEvents: boolean,
threadsEnabled: boolean,
@ -821,7 +821,7 @@ export default class MessagePanel extends React.Component<IProps, IState> {
// here.
return !this.props.canBackPaginate;
}
return wantsDateSeparator(prevEvent.getDate(), nextEventDate);
return wantsDateSeparator(prevEvent.getDate() || undefined, nextEventDate);
}
// Get a list of read receipts that should be shown next to this event

View file

@ -70,6 +70,8 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
() => this.animationCallback(),
() => requestAnimationFrame(() => this.scheduledUpdate.trigger()),
);
private startingPositionX = 0;
private startingPositionY = 0;
private _moving = false;
public get moving(): boolean {
@ -192,11 +194,22 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
event.stopPropagation();
this.mouseHeld = true;
this.startingPositionX = event.clientX;
this.startingPositionY = event.clientY;
};
private onMoving = (event: MouseEvent): void => {
if (!this.mouseHeld) return;
if (
Math.abs(this.startingPositionX - event.clientX) < 5 &&
Math.abs(this.startingPositionY - event.clientY) < 5
) {
// User needs to move the widget by at least five pixels.
// Improves click detection when using a touchpad or with nervous hands.
return;
}
event.preventDefault();
event.stopPropagation();

View file

@ -1,5 +1,5 @@
/*
Copyright 2021 - 2022 The Matrix.org Foundation C.I.C.
Copyright 2021 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -32,16 +32,9 @@ import { Layout } from "../../settings/enums/Layout";
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import Measured from "../views/elements/Measured";
import PosthogTrackers from "../../PosthogTrackers";
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
import { BetaPill } from "../views/beta/BetaCard";
import Modal from "../../Modal";
import BetaFeedbackDialog from "../views/dialogs/BetaFeedbackDialog";
import { Action } from "../../dispatcher/actions";
import { UserTab } from "../views/dialogs/UserTab";
import dis from "../../dispatcher/dispatcher";
import { ButtonEvent } from "../views/elements/AccessibleButton";
import Spinner from "../views/elements/Spinner";
import Heading from "../views/typography/Heading";
import { shouldShowFeedback } from "../../utils/Feedback";
interface IProps {
roomId: string;
@ -231,14 +224,6 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
}
}, [timelineSet, timelinePanel]);
const openFeedback = shouldShowFeedback()
? () => {
Modal.createDialog(BetaFeedbackDialog, {
featureId: "feature_threadenabled",
});
}
: null;
return (
<RoomContext.Provider
value={{
@ -256,32 +241,6 @@ const ThreadPanel: React.FC<IProps> = ({ roomId, onClose, permalinkCreator }) =>
empty={!hasThreads}
/>
}
footer={
<>
<BetaPill
tooltipTitle={_t("Threads are a beta feature")}
tooltipCaption={_t("Click for more info")}
onClick={() => {
dis.dispatch({
action: Action.ViewUserSettings,
initialTabId: UserTab.Labs,
});
}}
/>
{openFeedback &&
_t(
"<a>Give feedback</a>",
{},
{
a: (sub) => (
<AccessibleButton kind="link_inline" onClick={openFeedback}>
{sub}
</AccessibleButton>
),
},
)}
</>
}
className="mx_ThreadPanel"
onClose={onClose}
withoutScrollContainer={true}

View file

@ -1,8 +1,6 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2018 New Vector Ltd
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2015, 2016, 2018, 2019, 2020, 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -17,38 +15,46 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useContext, useEffect, useState } from "react";
import React, { CSSProperties, useCallback, useContext, useEffect, useState } from "react";
import classNames from "classnames";
import { ResizeMethod } from "matrix-js-sdk/src/@types/partials";
import { ClientEvent } from "matrix-js-sdk/src/client";
import { SyncState } from "matrix-js-sdk/src/sync";
import * as AvatarLogic from "../../../Avatar";
import SettingsStore from "../../../settings/SettingsStore";
import AccessibleButton from "../elements/AccessibleButton";
import RoomContext from "../../../contexts/RoomContext";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import { toPx } from "../../../utils/units";
import { _t } from "../../../languageHandler";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
interface IProps {
name: string; // The name (first initial used as default)
idName?: string; // ID for generating hash colours
title?: string; // onHover title text
url?: string; // highest priority of them all, shortcut to set in urls[0]
urls?: string[]; // [highest_priority, ... , lowest_priority]
/** The name (first initial used as default) */
name: string;
/** ID for generating hash colours */
idName?: string;
/** onHover title text */
title?: string;
/** highest priority of them all, shortcut to set in urls[0] */
url?: string;
/** [highest_priority, ... , lowest_priority] */
urls?: string[];
width?: number;
height?: number;
// XXX: resizeMethod not actually used.
/** @deprecated not actually used */
resizeMethod?: ResizeMethod;
defaultToInitialLetter?: boolean; // true to add default url
onClick?: React.MouseEventHandler;
/** true to add default url */
defaultToInitialLetter?: boolean;
onClick?: React.ComponentPropsWithoutRef<typeof AccessibleTooltipButton>["onClick"];
inputRef?: React.RefObject<HTMLImageElement & HTMLSpanElement>;
className?: string;
tabIndex?: number;
style?: CSSProperties;
}
const calculateUrls = (url: string, urls: string[], lowBandwidth: boolean): string[] => {
const calculateUrls = (url: string | undefined, urls: string[] | undefined, lowBandwidth: boolean): string[] => {
// work out the full set of urls to try to load. This is formed like so:
// imageUrls: [ props.url, ...props.urls ]
@ -66,11 +72,26 @@ const calculateUrls = (url: string, urls: string[], lowBandwidth: boolean): stri
return Array.from(new Set(_urls));
};
const useImageUrl = ({ url, urls }): [string, () => void] => {
/**
* Hook for cycling through a changing set of images.
*
* The set of images is updated whenever `url` or `urls` change, the user's
* `lowBandwidth` preference changes, or the client reconnects.
*
* Returns `[imageUrl, onError]`. When `onError` is called, the next image in
* the set will be displayed.
*/
const useImageUrl = ({
url,
urls,
}: {
url: string | undefined;
urls: string[] | undefined;
}): [string | undefined, () => void] => {
// Since this is a hot code path and the settings store can be slow, we
// use the cached lowBandwidth value from the room context if it exists
const roomContext = useContext(RoomContext);
const lowBandwidth = roomContext ? roomContext.lowBandwidth : SettingsStore.getValue("lowBandwidth");
const lowBandwidth = roomContext.lowBandwidth;
const [imageUrls, setUrls] = useState<string[]>(calculateUrls(url, urls, lowBandwidth));
const [urlsIndex, setIndex] = useState<number>(0);
@ -85,10 +106,10 @@ const useImageUrl = ({ url, urls }): [string, () => void] => {
}, [url, JSON.stringify(urls)]); // eslint-disable-line react-hooks/exhaustive-deps
const cli = useContext(MatrixClientContext);
const onClientSync = useCallback((syncState, prevState) => {
const onClientSync = useCallback((syncState: SyncState, prevState: SyncState | null) => {
// Consider the client reconnected if there is no error with syncing.
// This means the state could be RECONNECTING, SYNCING, PREPARED or CATCHUP.
const reconnected = syncState !== "ERROR" && prevState !== syncState;
const reconnected = syncState !== SyncState.Error && prevState !== syncState;
if (reconnected) {
setIndex(0);
}
@ -108,46 +129,25 @@ const BaseAvatar: React.FC<IProps> = (props) => {
urls,
width = 40,
height = 40,
resizeMethod = "crop", // eslint-disable-line @typescript-eslint/no-unused-vars
defaultToInitialLetter = true,
onClick,
inputRef,
className,
style: parentStyle,
resizeMethod: _unused, // to keep it from being in `otherProps`
...otherProps
} = props;
const style = {
...parentStyle,
width: toPx(width),
height: toPx(height),
};
const [imageUrl, onError] = useImageUrl({ url, urls });
if (!imageUrl && defaultToInitialLetter && name) {
const initialLetter = AvatarLogic.getInitialLetter(name);
const textNode = (
<span
className="mx_BaseAvatar_initial"
aria-hidden="true"
style={{
fontSize: toPx(width * 0.65),
width: toPx(width),
lineHeight: toPx(height),
}}
>
{initialLetter}
</span>
);
const imgNode = (
<img
className="mx_BaseAvatar_image"
src={AvatarLogic.defaultAvatarUrlForString(idName || name)}
alt=""
title={title}
onError={onError}
style={{
width: toPx(width),
height: toPx(height),
}}
aria-hidden="true"
data-testid="avatar-img"
/>
);
const avatar = <TextAvatar name={name} idName={idName} width={width} height={height} title={title} />;
if (onClick) {
return (
@ -159,9 +159,9 @@ const BaseAvatar: React.FC<IProps> = (props) => {
className={classNames("mx_BaseAvatar", className)}
onClick={onClick}
inputRef={inputRef}
style={style}
>
{textNode}
{imgNode}
{avatar}
</AccessibleButton>
);
} else {
@ -170,10 +170,10 @@ const BaseAvatar: React.FC<IProps> = (props) => {
className={classNames("mx_BaseAvatar", className)}
ref={inputRef}
{...otherProps}
style={style}
role="presentation"
>
{textNode}
{imgNode}
{avatar}
</span>
);
}
@ -187,10 +187,7 @@ const BaseAvatar: React.FC<IProps> = (props) => {
src={imageUrl}
onClick={onClick}
onError={onError}
style={{
width: toPx(width),
height: toPx(height),
}}
style={style}
title={title}
alt={_t("Avatar")}
inputRef={inputRef}
@ -204,10 +201,7 @@ const BaseAvatar: React.FC<IProps> = (props) => {
className={classNames("mx_BaseAvatar mx_BaseAvatar_image", className)}
src={imageUrl}
onError={onError}
style={{
width: toPx(width),
height: toPx(height),
}}
style={style}
title={title}
alt=""
ref={inputRef}
@ -220,3 +214,31 @@ const BaseAvatar: React.FC<IProps> = (props) => {
export default BaseAvatar;
export type BaseAvatarType = React.FC<IProps>;
const TextAvatar: React.FC<{
name: string;
idName?: string;
width: number;
height: number;
title?: string;
}> = ({ name, idName, width, height, title }) => {
const initialLetter = AvatarLogic.getInitialLetter(name);
return (
<span
className="mx_BaseAvatar_image mx_BaseAvatar_initial"
aria-hidden="true"
style={{
backgroundColor: AvatarLogic.getColorForString(idName || name),
width: toPx(width),
height: toPx(height),
fontSize: toPx(width * 0.65),
lineHeight: toPx(height),
}}
title={title}
data-testid="avatar-img"
>
{initialLetter}
</span>
);
};

View file

@ -1,6 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2019 - 2022 The Matrix.org Foundation C.I.C.
Copyright 2015, 2016, 2019 - 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -26,6 +25,7 @@ import { mediaFromMxc } from "../../../customisations/Media";
import { CardContext } from "../right_panel/context";
import UserIdentifierCustomisations from "../../../customisations/UserIdentifier";
import { useRoomMemberProfile } from "../../../hooks/room/useRoomMemberProfile";
import { ViewUserPayload } from "../../../dispatcher/payloads/ViewUserPayload";
interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url"> {
member: RoomMember | null;
@ -33,14 +33,13 @@ interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" |
width: number;
height: number;
resizeMethod?: ResizeMethod;
// The onClick to give the avatar
onClick?: React.MouseEventHandler;
// Whether the onClick of the avatar should be overridden to dispatch `Action.ViewUser`
/** Whether the onClick of the avatar should be overridden to dispatch `Action.ViewUser` */
viewUserOnClick?: boolean;
pushUserOnClick?: boolean;
title?: string;
style?: any;
forceHistorical?: boolean; // true to deny `useOnlyCurrentProfiles` usage. Default false.
style?: React.CSSProperties;
/** true to deny `useOnlyCurrentProfiles` usage. Default false. */
forceHistorical?: boolean;
hideTitle?: boolean;
}
@ -77,8 +76,8 @@ export default function MemberAvatar({
if (!title) {
title =
UserIdentifierCustomisations.getDisplayUserIdentifier(member?.userId ?? "", {
roomId: member?.roomId ?? "",
UserIdentifierCustomisations.getDisplayUserIdentifier!(member.userId, {
roomId: member.roomId,
}) ?? fallbackUserId;
}
}
@ -88,7 +87,6 @@ export default function MemberAvatar({
{...props}
width={width}
height={height}
resizeMethod={resizeMethod}
name={name ?? ""}
title={hideTitle ? undefined : title}
idName={member?.userId ?? fallbackUserId}
@ -96,9 +94,9 @@ export default function MemberAvatar({
onClick={
viewUserOnClick
? () => {
dis.dispatch({
dis.dispatch<ViewUserPayload>({
action: Action.ViewUser,
member: propsMember,
member: propsMember || undefined,
push: card.isCard,
});
}

View file

@ -109,7 +109,8 @@ export default class RoomAvatar extends React.Component<IProps, IState> {
}
private onRoomAvatarClick = (): void => {
const avatarUrl = Avatar.avatarUrlForRoom(this.props.room, null, null, null);
const avatarMxc = this.props.room?.getMxcAvatarUrl();
const avatarUrl = avatarMxc ? mediaFromMxc(avatarMxc).srcHttp : null;
const params = {
src: avatarUrl,
name: this.props.room.name,

View file

@ -1,6 +1,6 @@
/*
Copyright 2022 Michael Telatynski <7t3chguy@gmail.com>
Copyright 2018-2021 The Matrix.org Foundation C.I.C.
Copyright 2018-2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -32,6 +32,7 @@ import SettingsFlag from "../elements/SettingsFlag";
import { SettingLevel } from "../../../settings/SettingLevel";
import ServerInfo from "./devtools/ServerInfo";
import { Features } from "../../../settings/Settings";
import CopyableText from "../elements/CopyableText";
enum Category {
Room,
@ -119,11 +120,15 @@ const DevtoolsDialog: React.FC<IProps> = ({ roomId, onFinished }) => {
{(cli) => (
<>
<div className="mx_DevTools_label_left">{label}</div>
<div className="mx_DevTools_label_right">{_t("Room ID: %(roomId)s", { roomId })}</div>
<CopyableText className="mx_DevTools_label_right" getTextToCopy={() => roomId} border={false}>
{_t("Room ID: %(roomId)s", { roomId })}
</CopyableText>
<div className="mx_DevTools_label_bottom" />
<DevtoolsContext.Provider value={{ room: cli.getRoom(roomId) }}>
{body}
</DevtoolsContext.Provider>
{cli.getRoom(roomId) && (
<DevtoolsContext.Provider value={{ room: cli.getRoom(roomId)! }}>
{body}
</DevtoolsContext.Provider>
)}
</>
)}
</MatrixClientContext.Consumer>

View file

@ -130,7 +130,7 @@ export default class MessageEditHistoryDialog extends React.PureComponent<IProps
}
const baseEventId = this.props.mxEvent.getId();
allEvents.forEach((e, i) => {
if (!lastEvent || wantsDateSeparator(lastEvent.getDate(), e.getDate())) {
if (!lastEvent || wantsDateSeparator(lastEvent.getDate() || undefined, e.getDate() || undefined)) {
nodes.push(
<li key={e.getTs() + "~"}>
<DateSeparator roomId={e.getRoomId()} ts={e.getTs()} />

View file

@ -1,6 +1,6 @@
/*
Copyright 2018 New Vector Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2019, 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,7 +15,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { useCallback, useContext } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import dis from "../../../dispatcher/dispatcher";
@ -25,6 +26,8 @@ import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import EventTileBubble from "./EventTileBubble";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import RoomContext from "../../../contexts/RoomContext";
import { useRoomState } from "../../../hooks/useRoomState";
interface IProps {
/** The m.room.create MatrixEvent that this tile represents */
@ -36,44 +39,70 @@ interface IProps {
* A message tile showing that this room was created as an upgrade of a previous
* room.
*/
export default class RoomCreate extends React.Component<IProps> {
private onLinkClicked = (e: React.MouseEvent): void => {
e.preventDefault();
export const RoomCreate: React.FC<IProps> = ({ mxEvent, timestamp }) => {
// Note: we ask the room for its predecessor here, instead of directly using
// the information inside mxEvent. This allows us the flexibility later to
// use a different predecessor (e.g. through MSC3946) and still display it
// in the timeline location of the create event.
const roomContext = useContext(RoomContext);
const predecessor = useRoomState(
roomContext.room,
useCallback((state) => state.findPredecessor(), []),
);
const predecessor = this.props.mxEvent.getContent()["predecessor"];
const onLinkClicked = useCallback(
(e: React.MouseEvent): void => {
e.preventDefault();
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
event_id: predecessor["event_id"],
highlighted: true,
room_id: predecessor["room_id"],
metricsTrigger: "Predecessor",
metricsViaKeyboard: e.type !== "click",
});
};
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
event_id: predecessor.eventId,
highlighted: true,
room_id: predecessor.roomId,
metricsTrigger: "Predecessor",
metricsViaKeyboard: e.type !== "click",
});
},
[predecessor?.eventId, predecessor?.roomId],
);
public render(): JSX.Element {
const predecessor = this.props.mxEvent.getContent()["predecessor"];
if (predecessor === undefined) {
return <div />; // We should never have been instantiated in this case
}
const prevRoom = MatrixClientPeg.get().getRoom(predecessor["room_id"]);
const permalinkCreator = new RoomPermalinkCreator(prevRoom, predecessor["room_id"]);
permalinkCreator.load();
const predecessorPermalink = permalinkCreator.forEvent(predecessor["event_id"]);
const link = (
<a href={predecessorPermalink} onClick={this.onLinkClicked}>
{_t("Click here to see older messages.")}
</a>
);
return (
<EventTileBubble
className="mx_CreateEvent"
title={_t("This room is a continuation of another conversation.")}
subtitle={link}
timestamp={this.props.timestamp}
/>
if (!roomContext.room || roomContext.room.roomId !== mxEvent.getRoomId()) {
logger.warn(
"RoomCreate unexpectedly used outside of the context of the room containing this m.room.create event.",
);
return <></>;
}
}
if (!predecessor) {
logger.warn("RoomCreate unexpectedly used in a room with no predecessor.");
return <div />;
}
const prevRoom = MatrixClientPeg.get().getRoom(predecessor.roomId);
if (!prevRoom) {
logger.warn(`Failed to find predecessor room with id ${predecessor.roomId}`);
return <></>;
}
const permalinkCreator = new RoomPermalinkCreator(prevRoom, predecessor.roomId);
permalinkCreator.load();
let predecessorPermalink: string;
if (predecessor.eventId) {
predecessorPermalink = permalinkCreator.forEvent(predecessor.eventId);
} else {
predecessorPermalink = permalinkCreator.forRoom();
}
const link = (
<a href={predecessorPermalink} onClick={onLinkClicked}>
{_t("Click here to see older messages.")}
</a>
);
return (
<EventTileBubble
className="mx_CreateEvent"
title={_t("This room is a continuation of another conversation.")}
subtitle={link}
timestamp={timestamp}
/>
);
};

View file

@ -265,7 +265,7 @@ export default class TextualBody extends React.Component<IBodyProps, IState> {
// We don't use highlightElement here because we can't force language detection
// off. It should use the one we've found in the CSS class but we'd rather pass
// it in explicitly to make sure.
code.innerHTML = highlight.highlight(advertisedLang, code.textContent).value;
code.innerHTML = highlight.highlight(code.textContent, { language: advertisedLang }).value;
} else if (
SettingsStore.getValue("enableSyntaxHighlightLanguageDetection") &&
code.parentElement instanceof HTMLPreElement

View file

@ -22,7 +22,6 @@ import React from "react";
import classNames from "classnames";
import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { ThreadEvent } from "matrix-js-sdk/src/models/thread";
import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
import { _t } from "../../../languageHandler";
import HeaderButton from "./HeaderButton";
@ -39,12 +38,9 @@ import {
UPDATE_STATUS_INDICATOR,
} from "../../../stores/notifications/RoomNotificationStateStore";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { ThreadsRoomNotificationState } from "../../../stores/notifications/ThreadsRoomNotificationState";
import { SummarizedNotificationState } from "../../../stores/notifications/SummarizedNotificationState";
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
import PosthogTrackers from "../../../PosthogTrackers";
import { ButtonEvent } from "../elements/AccessibleButton";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { doesRoomOrThreadHaveUnreadMessages } from "../../../Unread";
const ROOM_INFO_PHASES = [
@ -133,74 +129,48 @@ interface IProps {
export default class RoomHeaderButtons extends HeaderButtons<IProps> {
private static readonly THREAD_PHASES = [RightPanelPhases.ThreadPanel, RightPanelPhases.ThreadView];
private threadNotificationState: ThreadsRoomNotificationState | null;
private globalNotificationState: SummarizedNotificationState;
private get supportsThreadNotifications(): boolean {
const client = MatrixClientPeg.get();
return client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported;
}
public constructor(props: IProps) {
super(props, HeaderKind.Room);
this.threadNotificationState =
!this.supportsThreadNotifications && this.props.room
? RoomNotificationStateStore.instance.getThreadsRoomState(this.props.room)
: null;
this.globalNotificationState = RoomNotificationStateStore.instance.globalState;
}
public componentDidMount(): void {
super.componentDidMount();
if (!this.supportsThreadNotifications) {
this.threadNotificationState?.on(NotificationStateEvents.Update, this.onNotificationUpdate);
} else {
// Notification badge may change if the notification counts from the
// server change, if a new thread is created or updated, or if a
// receipt is sent in the thread.
this.props.room?.on(RoomEvent.UnreadNotifications, this.onNotificationUpdate);
this.props.room?.on(RoomEvent.Receipt, this.onNotificationUpdate);
this.props.room?.on(RoomEvent.Timeline, this.onNotificationUpdate);
this.props.room?.on(RoomEvent.Redaction, this.onNotificationUpdate);
this.props.room?.on(RoomEvent.LocalEchoUpdated, this.onNotificationUpdate);
this.props.room?.on(RoomEvent.MyMembership, this.onNotificationUpdate);
this.props.room?.on(ThreadEvent.New, this.onNotificationUpdate);
this.props.room?.on(ThreadEvent.Update, this.onNotificationUpdate);
}
// Notification badge may change if the notification counts from the
// server change, if a new thread is created or updated, or if a
// receipt is sent in the thread.
this.props.room?.on(RoomEvent.UnreadNotifications, this.onNotificationUpdate);
this.props.room?.on(RoomEvent.Receipt, this.onNotificationUpdate);
this.props.room?.on(RoomEvent.Timeline, this.onNotificationUpdate);
this.props.room?.on(RoomEvent.Redaction, this.onNotificationUpdate);
this.props.room?.on(RoomEvent.LocalEchoUpdated, this.onNotificationUpdate);
this.props.room?.on(RoomEvent.MyMembership, this.onNotificationUpdate);
this.props.room?.on(ThreadEvent.New, this.onNotificationUpdate);
this.props.room?.on(ThreadEvent.Update, this.onNotificationUpdate);
this.onNotificationUpdate();
RoomNotificationStateStore.instance.on(UPDATE_STATUS_INDICATOR, this.onUpdateStatus);
}
public componentWillUnmount(): void {
super.componentWillUnmount();
if (!this.supportsThreadNotifications) {
this.threadNotificationState?.off(NotificationStateEvents.Update, this.onNotificationUpdate);
} else {
this.props.room?.off(RoomEvent.UnreadNotifications, this.onNotificationUpdate);
this.props.room?.off(RoomEvent.Receipt, this.onNotificationUpdate);
this.props.room?.off(RoomEvent.Timeline, this.onNotificationUpdate);
this.props.room?.off(RoomEvent.Redaction, this.onNotificationUpdate);
this.props.room?.off(RoomEvent.LocalEchoUpdated, this.onNotificationUpdate);
this.props.room?.off(RoomEvent.MyMembership, this.onNotificationUpdate);
this.props.room?.off(ThreadEvent.New, this.onNotificationUpdate);
this.props.room?.off(ThreadEvent.Update, this.onNotificationUpdate);
}
this.props.room?.off(RoomEvent.UnreadNotifications, this.onNotificationUpdate);
this.props.room?.off(RoomEvent.Receipt, this.onNotificationUpdate);
this.props.room?.off(RoomEvent.Timeline, this.onNotificationUpdate);
this.props.room?.off(RoomEvent.Redaction, this.onNotificationUpdate);
this.props.room?.off(RoomEvent.LocalEchoUpdated, this.onNotificationUpdate);
this.props.room?.off(RoomEvent.MyMembership, this.onNotificationUpdate);
this.props.room?.off(ThreadEvent.New, this.onNotificationUpdate);
this.props.room?.off(ThreadEvent.Update, this.onNotificationUpdate);
RoomNotificationStateStore.instance.off(UPDATE_STATUS_INDICATOR, this.onUpdateStatus);
}
private onNotificationUpdate = (): void => {
let threadNotificationColor: NotificationColor;
if (!this.supportsThreadNotifications) {
threadNotificationColor = this.threadNotificationState?.color ?? NotificationColor.None;
} else {
threadNotificationColor = this.notificationColor;
}
// console.log
// XXX: why don't we read from this.state.threadNotificationColor in the render methods?
this.setState({
threadNotificationColor,
threadNotificationColor: this.notificationColor,
});
};

View file

@ -353,25 +353,42 @@ export const UserOptionsSection: React.FC<{
});
};
const unignore = useCallback(() => {
const ignoredUsers = cli.getIgnoredUsers();
const index = ignoredUsers.indexOf(member.userId);
if (index !== -1) ignoredUsers.splice(index, 1);
cli.setIgnoredUsers(ignoredUsers);
}, [cli, member]);
const ignore = useCallback(async () => {
const { finished } = Modal.createDialog(QuestionDialog, {
title: _t("Ignore %(user)s", { user: member.name }),
description: (
<div>
{_t(
"All messages and invites from this user will be hidden. " +
"Are you sure you want to ignore them?",
)}
</div>
),
button: _t("Ignore"),
});
const [confirmed] = await finished;
if (confirmed) {
const ignoredUsers = cli.getIgnoredUsers();
ignoredUsers.push(member.userId);
cli.setIgnoredUsers(ignoredUsers);
}
}, [cli, member]);
// Only allow the user to ignore the user if its not ourselves
// same goes for jumping to read receipt
if (!isMe) {
const onIgnoreToggle = (): void => {
const ignoredUsers = cli.getIgnoredUsers();
if (isIgnored) {
const index = ignoredUsers.indexOf(member.userId);
if (index !== -1) ignoredUsers.splice(index, 1);
} else {
ignoredUsers.push(member.userId);
}
cli.setIgnoredUsers(ignoredUsers);
};
ignoreButton = (
<AccessibleButton
onClick={isIgnored ? unignore : ignore}
kind="link"
onClick={onIgnoreToggle}
className={classNames("mx_UserInfo_field", { mx_UserInfo_destructive: !isIgnored })}
>
{isIgnored ? _t("Unignore") : _t("Ignore")}

View file

@ -27,7 +27,6 @@ import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models
import { CallErrorCode } from "matrix-js-sdk/src/webrtc/call";
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { UserTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning";
import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
import ReplyChain from "../elements/ReplyChain";
import { _t } from "../../../languageHandler";
@ -62,10 +61,6 @@ import SettingsStore from "../../../settings/SettingsStore";
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import { ThreadNotificationState } from "../../../stores/notifications/ThreadNotificationState";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
import { NotificationColor } from "../../../stores/notifications/NotificationColor";
import { ButtonEvent } from "../elements/AccessibleButton";
import { copyPlaintext, getSelectedText } from "../../../utils/strings";
import { DecryptionFailureTracker } from "../../../DecryptionFailureTracker";
@ -254,7 +249,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
private isListeningForReceipts: boolean;
private tile = React.createRef<IEventTileType>();
private replyChain = React.createRef<ReplyChain>();
private threadState: ThreadNotificationState;
public readonly ref = createRef<HTMLElement>();
@ -389,10 +383,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
if (SettingsStore.getValue("feature_threadenabled")) {
this.props.mxEvent.on(ThreadEvent.Update, this.updateThread);
if (this.thread && !this.supportsThreadNotifications) {
this.setupNotificationListener(this.thread);
}
}
client.decryptEventIfNeeded(this.props.mxEvent);
@ -403,47 +393,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
this.verifyEvent();
}
private get supportsThreadNotifications(): boolean {
const client = MatrixClientPeg.get();
return client.canSupport.get(Feature.ThreadUnreadNotifications) !== ServerSupport.Unsupported;
}
private setupNotificationListener(thread: Thread): void {
if (!this.supportsThreadNotifications) {
const notifications = RoomNotificationStateStore.instance.getThreadsRoomState(thread.room);
this.threadState = notifications.getThreadRoomState(thread);
this.threadState.on(NotificationStateEvents.Update, this.onThreadStateUpdate);
this.onThreadStateUpdate();
}
}
private onThreadStateUpdate = (): void => {
if (!this.supportsThreadNotifications) {
let threadNotification = null;
switch (this.threadState?.color) {
case NotificationColor.Grey:
threadNotification = NotificationCountType.Total;
break;
case NotificationColor.Red:
threadNotification = NotificationCountType.Highlight;
break;
}
this.setState({
threadNotification,
});
}
};
private updateThread = (thread: Thread): void => {
if (thread !== this.state.thread && !this.supportsThreadNotifications) {
if (this.threadState) {
this.threadState.off(NotificationStateEvents.Update, this.onThreadStateUpdate);
}
this.setupNotificationListener(thread);
}
this.setState({ thread });
};
@ -473,7 +423,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
if (SettingsStore.getValue("feature_threadenabled")) {
this.props.mxEvent.off(ThreadEvent.Update, this.updateThread);
}
this.threadState?.off(NotificationStateEvents.Update, this.onThreadStateUpdate);
}
public componentDidUpdate(prevProps: Readonly<EventTileProps>, prevState: Readonly<IState>): void {
@ -1280,9 +1229,6 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
"data-shape": this.context.timelineRenderingType,
"data-self": isOwnEvent,
"data-has-reply": !!replyChain,
"data-notification": !this.supportsThreadNotifications
? this.state.threadNotification
: undefined,
"onMouseEnter": () => this.setState({ hover: true }),
"onMouseLeave": () => this.setState({ hover: false }),
"onClick": (ev: MouseEvent) => {
@ -1348,7 +1294,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
)}
{msgOption}
<UnreadNotificationBadge room={room} threadId={this.props.mxEvent.getId()} />
<UnreadNotificationBadge room={room || undefined} threadId={this.props.mxEvent.getId()} />
</>,
);
}

View file

@ -21,7 +21,7 @@ import { useUnreadNotifications } from "../../../../hooks/useUnreadNotifications
import { StatelessNotificationBadge } from "./StatelessNotificationBadge";
interface Props {
room: Room;
room?: Room;
threadId?: string;
}

View file

@ -84,7 +84,7 @@ export default class SearchResultTile extends React.Component<IProps> {
// is this a continuation of the previous message?
const continuation =
prevEv &&
!wantsDateSeparator(prevEv.getDate(), mxEv.getDate()) &&
!wantsDateSeparator(prevEv.getDate() || undefined, mxEv.getDate() || undefined) &&
shouldFormContinuation(
prevEv,
mxEv,
@ -96,7 +96,10 @@ export default class SearchResultTile extends React.Component<IProps> {
let lastInSection = true;
const nextEv = timeline[j + 1];
if (nextEv) {
const willWantDateSeparator = wantsDateSeparator(mxEv.getDate(), nextEv.getDate());
const willWantDateSeparator = wantsDateSeparator(
mxEv.getDate() || undefined,
nextEv.getDate() || undefined,
);
lastInSection =
willWantDateSeparator ||
mxEv.getSender() !== nextEv.getSender() ||

View file

@ -17,15 +17,18 @@ limitations under the License.
import { createContext, useContext } from "react";
import { SubSelection } from "./types";
import EditorStateTransfer from "../../../../utils/EditorStateTransfer";
export function getDefaultContextValue(): { selection: SubSelection } {
export function getDefaultContextValue(defaultValue?: Partial<ComposerContextState>): { selection: SubSelection } {
return {
selection: { anchorNode: null, anchorOffset: 0, focusNode: null, focusOffset: 0, isForward: true },
...defaultValue,
};
}
export interface ComposerContextState {
selection: SubSelection;
editorStateTransfer?: EditorStateTransfer;
}
export const ComposerContext = createContext<ComposerContextState>(getDefaultContextValue());

View file

@ -52,7 +52,7 @@ export default function EditWysiwygComposer({
className,
...props
}: EditWysiwygComposerProps): JSX.Element {
const defaultContextValue = useRef(getDefaultContextValue());
const defaultContextValue = useRef(getDefaultContextValue({ editorStateTransfer }));
const initialContent = useInitialContent(editorStateTransfer);
const isReady = !editorStateTransfer || initialContent !== undefined;

View file

@ -28,6 +28,8 @@ import { Icon as LinkIcon } from "../../../../../../res/img/element-icons/room/c
import { Icon as BulletedListIcon } from "../../../../../../res/img/element-icons/room/composer/bulleted_list.svg";
import { Icon as NumberedListIcon } from "../../../../../../res/img/element-icons/room/composer/numbered_list.svg";
import { Icon as CodeBlockIcon } from "../../../../../../res/img/element-icons/room/composer/code_block.svg";
import { Icon as IndentIcon } from "../../../../../../res/img/element-icons/room/composer/indent_increase.svg";
import { Icon as UnIndentIcon } from "../../../../../../res/img/element-icons/room/composer/indent_decrease.svg";
import AccessibleTooltipButton from "../../../elements/AccessibleTooltipButton";
import { Alignment } from "../../../elements/Tooltip";
import { KeyboardShortcut } from "../../../settings/KeyboardShortcut";
@ -127,6 +129,18 @@ export function FormattingButtons({ composer, actionStates }: FormattingButtonsP
onClick={() => composer.orderedList()}
icon={<NumberedListIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.indent}
label={_td("Indent increase")}
onClick={() => composer.indent()}
icon={<IndentIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.unIndent}
label={_td("Indent decrease")}
onClick={() => composer.unIndent()}
icon={<UnIndentIcon className="mx_FormattingButtons_Icon" />}
/>
<Button
actionState={actionStates.quote}
label={_td("Quote")}

View file

@ -47,7 +47,7 @@ export const WysiwygComposer = memo(function WysiwygComposer({
rightComponent,
children,
}: WysiwygComposerProps) {
const inputEventProcessor = useInputEventProcessor(onSend);
const inputEventProcessor = useInputEventProcessor(onSend, initialContent);
const { ref, isWysiwygReady, content, actionStates, wysiwyg } = useWysiwyg({ initialContent, inputEventProcessor });

View file

@ -14,40 +14,168 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { WysiwygEvent } from "@matrix-org/matrix-wysiwyg";
import { Wysiwyg, WysiwygEvent } from "@matrix-org/matrix-wysiwyg";
import { useCallback } from "react";
import { MatrixClient } from "matrix-js-sdk/src/matrix";
import { useSettingValue } from "../../../../../hooks/useSettings";
import { getKeyBindingsManager } from "../../../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../../../accessibility/KeyboardShortcuts";
import { findEditableEvent } from "../../../../../utils/EventUtils";
import dis from "../../../../../dispatcher/dispatcher";
import { Action } from "../../../../../dispatcher/actions";
import { useRoomContext } from "../../../../../contexts/RoomContext";
import { IRoomState } from "../../../../structures/RoomView";
import { ComposerContextState, useComposerContext } from "../ComposerContext";
import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
import { isCaretAtEnd, isCaretAtStart } from "../utils/selection";
import { getEventsFromEditorStateTransfer } from "../utils/event";
import { endEditing } from "../utils/editing";
function isEnterPressed(event: KeyboardEvent): boolean {
// Ugly but here we need to send the message only if Enter is pressed
// And we need to stop the event propagation on enter to avoid the composer to grow
return event.key === "Enter" && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey;
}
export function useInputEventProcessor(
onSend: () => void,
initialContent?: string,
): (event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => WysiwygEvent | null {
const roomContext = useRoomContext();
const composerContext = useComposerContext();
const mxClient = useMatrixClientContext();
const isCtrlEnterToSend = useSettingValue<boolean>("MessageComposerInput.ctrlEnterToSend");
export function useInputEventProcessor(onSend: () => void): (event: WysiwygEvent) => WysiwygEvent | null {
const isCtrlEnter = useSettingValue<boolean>("MessageComposerInput.ctrlEnterToSend");
return useCallback(
(event: WysiwygEvent) => {
(event: WysiwygEvent, composer: Wysiwyg, editor: HTMLElement) => {
if (event instanceof ClipboardEvent) {
return event;
}
const isKeyboardEvent = event instanceof KeyboardEvent;
const isEnterPress = !isCtrlEnter && isKeyboardEvent && isEnterPressed(event);
const isInsertParagraph = !isCtrlEnter && !isKeyboardEvent && event.inputType === "insertParagraph";
// sendMessage is sent when cmd+enter is pressed
const isSendMessage = isCtrlEnter && !isKeyboardEvent && event.inputType === "sendMessage";
if (isEnterPress || isInsertParagraph || isSendMessage) {
const send = (): void => {
event.stopPropagation?.();
event.preventDefault?.();
onSend();
};
const isKeyboardEvent = event instanceof KeyboardEvent;
if (isKeyboardEvent) {
return handleKeyboardEvent(
event,
send,
initialContent,
composer,
editor,
roomContext,
composerContext,
mxClient,
);
} else {
return handleInputEvent(event, send, isCtrlEnterToSend);
}
},
[isCtrlEnterToSend, onSend, initialContent, roomContext, composerContext, mxClient],
);
}
type Send = () => void;
function handleKeyboardEvent(
event: KeyboardEvent,
send: Send,
initialContent: string | undefined,
composer: Wysiwyg,
editor: HTMLElement,
roomContext: IRoomState,
composerContext: ComposerContextState,
mxClient: MatrixClient,
): KeyboardEvent | null {
const { editorStateTransfer } = composerContext;
const isEditorModified = initialContent !== composer.content();
const action = getKeyBindingsManager().getMessageComposerAction(event);
switch (action) {
case KeyBindingAction.SendMessage:
send();
return null;
case KeyBindingAction.EditPrevMessage: {
// If not in edition
// Or if the caret is not at the beginning of the editor
// Or the editor is modified
if (!editorStateTransfer || !isCaretAtStart(editor) || isEditorModified) {
break;
}
const isDispatched = dispatchEditEvent(event, false, editorStateTransfer, roomContext, mxClient);
if (isDispatched) {
return null;
}
return event;
},
[isCtrlEnter, onSend],
);
break;
}
case KeyBindingAction.EditNextMessage: {
// If not in edition
// Or if the caret is not at the end of the editor
// Or the editor is modified
if (!editorStateTransfer || !isCaretAtEnd(editor) || isEditorModified) {
break;
}
const isDispatched = dispatchEditEvent(event, true, editorStateTransfer, roomContext, mxClient);
if (!isDispatched) {
endEditing(roomContext);
event.preventDefault();
event.stopPropagation();
}
return null;
}
}
return event;
}
function dispatchEditEvent(
event: KeyboardEvent,
isForward: boolean,
editorStateTransfer: EditorStateTransfer,
roomContext: IRoomState,
mxClient: MatrixClient,
): boolean {
const foundEvents = getEventsFromEditorStateTransfer(editorStateTransfer, roomContext, mxClient);
if (!foundEvents) {
return false;
}
const newEvent = findEditableEvent({
events: foundEvents,
isForward,
fromEventId: editorStateTransfer.getEvent().getId(),
});
if (newEvent) {
dis.dispatch({
action: Action.EditEvent,
event: newEvent,
timelineRenderingType: roomContext.timelineRenderingType,
});
event.stopPropagation();
event.preventDefault();
return true;
}
return false;
}
type InputEvent = Exclude<WysiwygEvent, KeyboardEvent | ClipboardEvent>;
function handleInputEvent(event: InputEvent, send: Send, isCtrlEnterToSend: boolean): InputEvent | null {
switch (event.inputType) {
case "insertParagraph":
if (!isCtrlEnterToSend) {
send();
}
return null;
case "sendMessage":
if (isCtrlEnterToSend) {
send();
}
return null;
}
return event;
}

View file

@ -77,6 +77,7 @@ export function usePlainTextListeners(
const onKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === Key.ENTER) {
// TODO use getKeyBindingsManager().getMessageComposerAction(event) like in useInputEventProcessor
const sendModifierIsPressed = IS_MAC ? event.metaKey : event.ctrlKey;
// if enter should send, send if the user is not pushing shift

View file

@ -0,0 +1,46 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix";
import EditorStateTransfer from "../../../../../utils/EditorStateTransfer";
import { IRoomState } from "../../../../structures/RoomView";
// From EditMessageComposer private get events(): MatrixEvent[]
export function getEventsFromEditorStateTransfer(
editorStateTransfer: EditorStateTransfer,
roomContext: IRoomState,
mxClient: MatrixClient,
): MatrixEvent[] | undefined {
const liveTimelineEvents = roomContext.liveTimeline?.getEvents();
if (!liveTimelineEvents) {
return;
}
const roomId = editorStateTransfer.getEvent().getRoomId();
if (!roomId) {
return;
}
const room = mxClient.getRoom(roomId);
if (!room) {
return;
}
const pendingEvents = room.getPendingEvents();
const isInThread = Boolean(editorStateTransfer.getEvent().getThread());
return liveTimelineEvents.concat(isInThread ? [] : pendingEvents);
}

View file

@ -39,3 +39,50 @@ export function isSelectionEmpty(): boolean {
const selection = document.getSelection();
return Boolean(selection?.isCollapsed);
}
export function isCaretAtStart(editor: HTMLElement): boolean {
const selection = document.getSelection();
// No selection or the caret is not at the beginning of the selected element
if (!selection || selection.anchorOffset !== 0) {
return false;
}
// In case of nested html elements (list, code blocks), we are going through all the first child
let child = editor.firstChild;
do {
if (child === selection.anchorNode) {
return true;
}
} while ((child = child?.firstChild || null));
return false;
}
export function isCaretAtEnd(editor: HTMLElement): boolean {
const selection = document.getSelection();
if (!selection) {
return false;
}
// When we are cycling across all the timeline message with the keyboard
// The caret is on the last text element but focusNode and anchorNode refers to the editor div
// In this case, the focusOffset & anchorOffset match the index + 1 of the selected text
const isOnLastElement = selection.focusNode === editor && selection.focusOffset === editor.childNodes?.length;
if (isOnLastElement) {
return true;
}
// In case of nested html elements (list, code blocks), we are going through all the last child
// The last child of the editor is always a <br> tag, we skip it
let child: ChildNode | null = editor.childNodes.item(editor.childNodes.length - 2);
do {
if (child === selection.focusNode) {
// Checking that the cursor is at end of the selected text
return selection.focusOffset === child.textContent?.length;
}
} while ((child = child.lastChild));
return false;
}