Merge branch 'develop' into johannes/latest-room-in-space
This commit is contained in:
commit
3766b39361
119 changed files with 4636 additions and 1409 deletions
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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()} />
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -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")}
|
||||
|
|
|
@ -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()} />
|
||||
</>,
|
||||
);
|
||||
}
|
||||
|
|
|
@ -21,7 +21,7 @@ import { useUnreadNotifications } from "../../../../hooks/useUnreadNotifications
|
|||
import { StatelessNotificationBadge } from "./StatelessNotificationBadge";
|
||||
|
||||
interface Props {
|
||||
room: Room;
|
||||
room?: Room;
|
||||
threadId?: string;
|
||||
}
|
||||
|
||||
|
|
|
@ -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() ||
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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")}
|
||||
|
|
|
@ -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 });
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
46
src/components/views/rooms/wysiwyg_composer/utils/event.ts
Normal file
46
src/components/views/rooms/wysiwyg_composer/utils/event.ts
Normal 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);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue